Compare commits
8
Commits
audit
...
v2026.11.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f618cb9457 | ||
|
|
acced53c06 | ||
|
|
c056b06655 | ||
|
|
e1c3dbff52 | ||
|
|
620cbaf89c | ||
|
|
8fa79aef33 | ||
|
|
8acbbaef5f | ||
|
|
85f2bb9885 |
@@ -24,7 +24,7 @@ jobs:
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- platform: linux-x64
|
||||
os: ubuntu-latest
|
||||
os: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- platform: linux-arm64
|
||||
os: ubuntu-24.04-arm
|
||||
|
||||
Generated
+213
-174
File diff suppressed because it is too large
Load Diff
+8
-11
@@ -4,23 +4,20 @@ members = ["crates/*", "desktop", "web"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "2.0.0-alpha3"
|
||||
version = "2026.11.1"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
gpui = { package = "gpui-pre", version = "0.3.10" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.10", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { package = "gpui-pre-linux", version = "0.3.10" }
|
||||
gpui_windows = { package = "gpui-pre-windows", version = "0.3.10" }
|
||||
gpui_macos = { package = "gpui-pre-macos", version = "0.3.10" }
|
||||
gpui_web = { package = "gpui-pre-web", version = "0.3.10" }
|
||||
gpui_util = { package = "gpui-pre-util", version = "0.3.10" }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.10" }
|
||||
gpui = { package = "gpui-pre", version = "0.3.12" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.12", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_web = { package = "gpui-pre-web", version = "0.3.12" }
|
||||
gpui_util = { package = "gpui-pre-util", version = "0.3.12" }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.12" }
|
||||
gpui_tokio = { path = "crates/gpui_tokio" }
|
||||
|
||||
gpui-base = "0.8.1"
|
||||
gpui-kit = "0.8.1"
|
||||
gpui-base = "0.9.1"
|
||||
gpui-kit = "0.9.1"
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
@@ -6,7 +6,6 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
rust-embed.workspace = true
|
||||
gpui-kit.workspace = true
|
||||
|
||||
|
||||
@@ -63,6 +63,18 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||
|
||||
impl Global for GlobalAutoUpdater {}
|
||||
|
||||
/// The subset of updater state the UI renders, so views can hold it in a small
|
||||
/// entity instead of observing the whole updater.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct UpdateDisplay {
|
||||
/// Whether the running version is the newest release.
|
||||
pub up_to_date: bool,
|
||||
/// Whether a verified update is installed and waiting for a restart.
|
||||
pub staged: bool,
|
||||
/// A short, human-readable description of the current status.
|
||||
pub status: SharedString,
|
||||
}
|
||||
|
||||
pub struct AutoUpdater {
|
||||
updater: Entity<Updater>,
|
||||
status: UpdateStatus,
|
||||
@@ -173,6 +185,15 @@ impl AutoUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the state the UI renders.
|
||||
pub fn display(&self) -> UpdateDisplay {
|
||||
UpdateDisplay {
|
||||
up_to_date: self.up_to_date(),
|
||||
staged: self.staged(),
|
||||
status: self.status(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the release host for a newer version, then download and install it.
|
||||
pub fn check(&mut self, cx: &mut Context<Self>) {
|
||||
self.updater.update(cx, |updater, cx| {
|
||||
|
||||
@@ -5,7 +5,7 @@ async function pollForRequests() {
|
||||
isPolling = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pending');
|
||||
const response = await fetch('/api/pending?token=' + encodeURIComponent(window.proxySessionToken));
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Polled for requests, got:', data);
|
||||
@@ -94,7 +94,7 @@ async function handleNip07Request(request) {
|
||||
|
||||
console.log('Sending response:', responsePayload);
|
||||
|
||||
await fetch('/api/response', {
|
||||
await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -117,7 +117,7 @@ async function handleNip07Request(request) {
|
||||
|
||||
console.log('Sending error response:', errorPayload);
|
||||
|
||||
await fetch('/api/response', {
|
||||
await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -26,6 +26,13 @@ pub use self::error::Error;
|
||||
const DEFAULT_HTML: &str = include_str!("../index.html");
|
||||
const JS: &str = include_str!("../proxy.js");
|
||||
|
||||
/// Cap on the request line and headers so a peer cannot force unbounded reads
|
||||
const MAX_HEADER_BYTES: usize = 16 * 1024;
|
||||
/// Cap on the request body so a huge Content-Length cannot force a huge allocation
|
||||
const MAX_BODY_BYTES: usize = 1024 * 1024;
|
||||
/// Maximum time a connection may stay open before it is dropped
|
||||
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -265,6 +272,10 @@ impl BrowserSignerProxy {
|
||||
let state: Arc<ProxyState> = self.inner.state.clone();
|
||||
let custom_html: &'static str = self.inner.options.custom_html;
|
||||
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 {
|
||||
tracing::info!("Starting proxy server on {addr}");
|
||||
@@ -291,13 +302,30 @@ impl BrowserSignerProxy {
|
||||
AcceptEvent::Connection(Ok((stream, _))) => {
|
||||
let state: Arc<ProxyState> = state.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 {
|
||||
let shutdown_listener = shutdown.listen();
|
||||
|
||||
smol::future::or(
|
||||
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 {
|
||||
shutdown_listener.await;
|
||||
@@ -336,6 +364,18 @@ impl BrowserSignerProxy {
|
||||
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>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
@@ -345,6 +385,7 @@ impl BrowserSignerProxy {
|
||||
|
||||
// Construct the request
|
||||
let request: RequestData = RequestData::new(method, params);
|
||||
let request_id = request.id;
|
||||
|
||||
// Create a bounded channel of size 1 as a oneshot replacement
|
||||
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
|
||||
@@ -361,7 +402,10 @@ impl BrowserSignerProxy {
|
||||
match response {
|
||||
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
|
||||
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]
|
||||
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
|
||||
let expected_id: EventId = event.compute_id();
|
||||
let event: Event = self
|
||||
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
|
||||
.await?;
|
||||
event.verify()?;
|
||||
|
||||
if event.id != expected_id {
|
||||
return Err(Error::generic(format!(
|
||||
"signed event doesn't match the requested event: expected ID {expected_id}, got {}",
|
||||
event.id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
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 header_bytes: usize = 0;
|
||||
|
||||
// Read the request line
|
||||
let mut request_line = String::new();
|
||||
if reader.read_line(&mut request_line).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let request_line = request_line.trim_end().to_string();
|
||||
let request_line = match read_line_capped(&mut reader, MAX_HEADER_BYTES).await {
|
||||
Ok(HeaderLine::Line(line)) => line,
|
||||
Ok(HeaderLine::Eof) => Vec::new(),
|
||||
Ok(HeaderLine::TooLong) => {
|
||||
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||
return;
|
||||
}
|
||||
Err(_) => return,
|
||||
};
|
||||
header_bytes += request_line.len();
|
||||
let request_line = match String::from_utf8(request_line) {
|
||||
Ok(line) => line.trim_end().to_string(),
|
||||
Err(_) => {
|
||||
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse method, path, and HTTP version from request line
|
||||
let parts: Vec<&str> = request_line.split_whitespace().collect();
|
||||
@@ -462,24 +580,72 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
|
||||
// Read headers until empty line
|
||||
let mut headers = Vec::new();
|
||||
let mut content_length: usize = 0;
|
||||
let mut host: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).await.is_err() {
|
||||
let line = match read_line_capped(&mut reader, MAX_HEADER_BYTES).await {
|
||||
Ok(HeaderLine::Line(line)) => line,
|
||||
Ok(HeaderLine::Eof) => break,
|
||||
Ok(HeaderLine::TooLong) => {
|
||||
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||
return;
|
||||
}
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
header_bytes += line.len();
|
||||
if header_bytes > MAX_HEADER_BYTES {
|
||||
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||
return;
|
||||
}
|
||||
let line = line.trim_end().to_string();
|
||||
|
||||
let line = match String::from_utf8(line) {
|
||||
Ok(line) => line.trim_end().to_string(),
|
||||
Err(_) => {
|
||||
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(value) = line.strip_prefix("content-length:") {
|
||||
content_length = value.trim().parse().unwrap_or(0);
|
||||
} else if let Some(value) = line.strip_prefix("Content-Length:") {
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
("GET", "/") => {
|
||||
let html = if custom_html.is_empty() {
|
||||
@@ -487,7 +653,8 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
|
||||
} else {
|
||||
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
|
||||
("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
|
||||
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
|
||||
("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 mut pending = state.pending_responses.lock().await;
|
||||
|
||||
match pending.remove(&id) {
|
||||
Some(sender) => {
|
||||
// Use try_send since we already hold the lock
|
||||
let _ = sender.try_send(message.into_result());
|
||||
tracing::info!("Forwarded response for request {id}");
|
||||
}
|
||||
Some(sender) => match sender.try_send(message.into_result()) {
|
||||
Ok(()) => tracing::info!("Forwarded response for request {id}"),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to forward response for request {id}: {e}")
|
||||
}
|
||||
},
|
||||
None => tracing::warn!("No pending request found for {id}"),
|
||||
}
|
||||
|
||||
send_response_cors(&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;
|
||||
send_response(&mut reader, 200, "OK", "text/plain", "OK").await;
|
||||
}
|
||||
// 404 - not found
|
||||
_ => {
|
||||
@@ -585,68 +751,44 @@ async fn send_response(
|
||||
}
|
||||
|
||||
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_response(stream, &response).await;
|
||||
}
|
||||
|
||||
/// Write a response with CORS headers and JSON content type.
|
||||
async fn send_response_cors_json(
|
||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||
status: u16,
|
||||
status_text: &str,
|
||||
body: &str,
|
||||
) {
|
||||
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"));
|
||||
async fn write_response(stream: &mut (impl AsyncWriteExt + Unpin), response: &str) {
|
||||
if let Err(e) = stream.write_all(response.as_bytes()).await {
|
||||
tracing::debug!("Failed to write response to peer: {e}");
|
||||
}
|
||||
if let Err(e) = stream.flush().await {
|
||||
tracing::debug!("Failed to flush response to peer: {e}");
|
||||
}
|
||||
|
||||
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.
|
||||
#[derive(Debug)]
|
||||
struct TimeoutError;
|
||||
|
||||
/// 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>(
|
||||
duration: Duration,
|
||||
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]
|
||||
fn current_time() -> u64 {
|
||||
SystemTime::now()
|
||||
|
||||
@@ -24,6 +24,3 @@ flume.workspace = true
|
||||
|
||||
futures.workspace = true
|
||||
fuzzy-matcher = "0.3.7"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
|
||||
+67
-3
@@ -396,10 +396,21 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
let mut newly_trashed = 0;
|
||||
|
||||
trash.update(cx, |trash, cx| {
|
||||
trash.extend(errors);
|
||||
newly_trashed = errors
|
||||
.iter()
|
||||
.filter(|message| trash.insert((*message).clone()))
|
||||
.count();
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
if newly_trashed > 0 {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(ChatEvent::Ping);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
if eose && tracking.swap(false, Ordering::AcqRel) {
|
||||
@@ -539,7 +550,7 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Retry unwrapping trashed gift wraps with the newly available encryption key.
|
||||
fn reprocess_trash(&mut self, cx: &mut Context<Self>) {
|
||||
pub fn reprocess_trash(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
@@ -615,6 +626,32 @@ impl ChatRegistry {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// How many messages are waiting in the trash.
|
||||
pub fn trashed_count(&self, cx: &App) -> usize {
|
||||
self.trash.read(cx).len()
|
||||
}
|
||||
|
||||
/// The messages that could not be decrypted, newest first.
|
||||
pub fn trashed(&self, cx: &App) -> Vec<TrashedMessage> {
|
||||
let mut messages: Vec<TrashedMessage> = self
|
||||
.trash
|
||||
.read(cx)
|
||||
.iter()
|
||||
.filter_map(|message| {
|
||||
let event = Event::from_json(message.raw_event.as_str()).ok()?;
|
||||
|
||||
Some(TrashedMessage {
|
||||
id: event.id,
|
||||
created_at: event.created_at,
|
||||
reason: message.reason.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
messages.sort_by_key(|message| std::cmp::Reverse(message.created_at));
|
||||
messages
|
||||
}
|
||||
|
||||
/// Get the relays that have seen a given rumor id.
|
||||
pub fn rumor_seen_on(&self, id: &EventId) -> Option<HashSet<RelayUrl>> {
|
||||
self.event_map
|
||||
@@ -837,8 +874,9 @@ impl ChatRegistry {
|
||||
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
|
||||
|
||||
let events = client.database().query(filter).await?;
|
||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||
|
||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||
let mut stale: Vec<(EventId, UnsignedEvent)> = Vec::new();
|
||||
// Keep the single-threaded web event loop responsive during large rescans
|
||||
let mut last_yield = Instant::now();
|
||||
|
||||
@@ -852,15 +890,41 @@ impl ChatRegistry {
|
||||
&& rumor.tags.public_keys().next().is_some()
|
||||
{
|
||||
search.write().unwrap().insert(&rumor);
|
||||
|
||||
if rumor.pubkey != public_key
|
||||
&& !rumor.tags.public_keys().any(|k| k == public_key)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let room_id = rumor.uniq_id().to_string();
|
||||
let stored_room = raw
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "r")
|
||||
.and_then(|tag| tag.content());
|
||||
|
||||
if stored_room.is_none_or(|stored| stored != room_id)
|
||||
&& let Some(gift_wrap) = raw
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "d")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|value| EventId::from_hex(value).ok())
|
||||
{
|
||||
stale.push((gift_wrap, rumor.clone()));
|
||||
}
|
||||
|
||||
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
||||
}
|
||||
}
|
||||
|
||||
for (gift_wrap, rumor) in stale {
|
||||
if let Err(error) = set_rumor(&client, gift_wrap, &rumor).await {
|
||||
log::warn!("Failed to re-tag a cached message: {error:#}");
|
||||
}
|
||||
}
|
||||
|
||||
let mut rooms = HashSet::with_capacity(grouped.len());
|
||||
|
||||
for (_id, messages) in grouped.into_iter() {
|
||||
|
||||
@@ -219,6 +219,17 @@ impl FailedMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// A message that could not be decrypted, as surfaced to the UI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrashedMessage {
|
||||
/// The undecryptable gift wrap's id, stable across renders
|
||||
pub id: EventId,
|
||||
/// When the gift wrap was received
|
||||
pub created_at: Timestamp,
|
||||
/// Why it could not be decrypted
|
||||
pub reason: SharedString,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Mention {
|
||||
pub public_key: PublicKey,
|
||||
|
||||
@@ -18,6 +18,7 @@ gpui.workspace = true
|
||||
instant.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
itertools.workspace = true
|
||||
smallvec.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
+113
-29
@@ -1,3 +1,4 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::{Arc, LazyLock, RwLock};
|
||||
use std::time::Duration;
|
||||
@@ -56,6 +57,7 @@ static CUSTOM_EMOJI_RE: LazyLock<Regex> =
|
||||
|
||||
mod actions;
|
||||
mod file;
|
||||
pub mod trash;
|
||||
|
||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||
cx.new(|cx| ChatPanel::new(room, window, cx))
|
||||
@@ -144,7 +146,6 @@ impl ChatPanel {
|
||||
})
|
||||
.unwrap_or(("Unknown".into(), "Message...".into()));
|
||||
|
||||
// Define input state
|
||||
let input = cx.new(|cx| {
|
||||
TextareaState::new(window, cx)
|
||||
.placeholder(format!("Message {}", name))
|
||||
@@ -152,7 +153,6 @@ impl ChatPanel {
|
||||
.clean_on_escape()
|
||||
});
|
||||
|
||||
// Define dictation state
|
||||
let speech = cx.new(|cx| {
|
||||
let state = SpeechState::new(cx)
|
||||
.recognizer(speech::recognizer())
|
||||
@@ -165,11 +165,9 @@ impl ChatPanel {
|
||||
state
|
||||
});
|
||||
|
||||
// Define subject input state
|
||||
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
|
||||
let subject_bar = cx.new(|_cx| false);
|
||||
|
||||
// Define subscriptions
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
@@ -218,9 +216,8 @@ impl ChatPanel {
|
||||
}),
|
||||
);
|
||||
|
||||
// Define all functions that will run after the current cycle
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.connect(cx);
|
||||
this.connect(window, cx);
|
||||
this.subscribe_room_events(window, cx);
|
||||
this.get_messages(window, cx);
|
||||
});
|
||||
@@ -269,11 +266,24 @@ impl ChatPanel {
|
||||
}
|
||||
|
||||
/// Get messaging relays and announcement for each member
|
||||
fn connect(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(room) = self.room.upgrade() {
|
||||
let task = room.read(cx).connect(cx);
|
||||
self.tasks.push(task);
|
||||
}
|
||||
fn connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(room) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let connect = room.read(cx).connect(cx);
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
if let Err(error) = connect.await {
|
||||
log::error!("Connecting room relays failed: {error}");
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.notify_failure("Connection failed", error, window, cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Subscribe to room events
|
||||
@@ -317,23 +327,40 @@ impl ChatPanel {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return;
|
||||
};
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let events = get_messages.await?;
|
||||
|
||||
// Update message list
|
||||
this.update(cx, |this, cx| {
|
||||
this.insert_messages(&events, cx);
|
||||
})?;
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
match get_messages.await {
|
||||
Ok(events) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.insert_messages(&events, cx);
|
||||
})?;
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("Loading room messages failed: {error}");
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.notify_failure("Failed to load messages", error, window, cx);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Show a failure of a background operation to the user until dismissed
|
||||
fn notify_failure(&self, title: &'static str, error: Error, window: &mut Window, cx: &mut App) {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string())
|
||||
.title(title)
|
||||
.autohide(false),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get user input content and merged all attachments if available
|
||||
fn get_input_value(&self, cx: &Context<Self>) -> String {
|
||||
// Get input's value
|
||||
@@ -566,8 +593,9 @@ impl ChatPanel {
|
||||
for (i, message) in self.messages.iter().enumerate().skip(pos) {
|
||||
self.message_index.insert(message.id, i);
|
||||
}
|
||||
let len = self.list_state.item_count();
|
||||
self.list_state.splice(len..len, 1);
|
||||
// The welcome row occupies row zero, so message rows sit one ahead
|
||||
let row = pos + 1;
|
||||
self.list_state.splice(row..row, 1);
|
||||
|
||||
if scroll {
|
||||
self.list_state.scroll_to(ListOffset {
|
||||
@@ -582,13 +610,62 @@ impl ChatPanel {
|
||||
|
||||
/// Convert and insert a vector of nostr events into the chat panel
|
||||
fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context<Self>) {
|
||||
let mut incoming: Vec<Message> = Vec::with_capacity(events.len());
|
||||
|
||||
for event in events.iter() {
|
||||
if event.kind == Kind::Reaction {
|
||||
self.insert_reaction(event, cx);
|
||||
} else {
|
||||
incoming.push(event.into());
|
||||
}
|
||||
}
|
||||
|
||||
incoming.sort();
|
||||
|
||||
let old_len = self.messages.len();
|
||||
let mut stored = std::mem::take(&mut self.messages).into_iter().peekable();
|
||||
let mut merged: Vec<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;
|
||||
}
|
||||
// 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 +675,23 @@ impl ChatPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = SharedString::from(&event.content);
|
||||
let emojis = emojis_from_tags(&event.tags);
|
||||
|
||||
let image = Message::reaction_image(&event.content, &emojis)
|
||||
.map(|url| SharedUri::from(url.to_string()));
|
||||
|
||||
for id in event.tags.event_ids() {
|
||||
self.reactions.entry(id).or_default().push((
|
||||
SharedString::from(&event.content),
|
||||
image.clone(),
|
||||
event.pubkey,
|
||||
));
|
||||
let entries = self.reactions.entry(id).or_default();
|
||||
// NIP-25: a newer reaction by the same author replaces their previous one
|
||||
if let Some(existing) = entries
|
||||
.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();
|
||||
@@ -1101,7 +1185,7 @@ impl ChatPanel {
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().list_active),
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(PRIVATE_NOTICE)
|
||||
.into_any_element()
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use chat::{ChatRegistry, TrashedMessage};
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||
ParentElement, Render, Styled, Window, div, uniform_list,
|
||||
};
|
||||
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, h_flex, v_flex};
|
||||
use ui::IconName;
|
||||
|
||||
/// Initialize the trash panel.
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> gpui::Entity<TrashPanel> {
|
||||
cx.new(|cx| TrashPanel::new(window, cx))
|
||||
}
|
||||
|
||||
/// The messages that could not be decrypted, shown like a chat room.
|
||||
pub struct TrashPanel {
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl TrashPanel {
|
||||
fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_entry(&self, message: &TrashedMessage, cx: &Context<Self>) -> AnyElement {
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().secondary.opacity(0.5))
|
||||
.child(
|
||||
Icon::new(IconName::Warning)
|
||||
.small()
|
||||
.text_color(cx.theme().warning),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(div().text_sm().child("Could not be decrypted"))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.truncate()
|
||||
.child(message.reason.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(message.created_at.to_ago()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_empty(&self, cx: &Context<Self>) -> AnyElement {
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Trash is empty")
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui_kit::component::dock::BasePanel for TrashPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"TrashPanel"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for TrashPanel {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
Icon::new(IconName::Warning)
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child("Trash")
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for TrashPanel {}
|
||||
|
||||
impl Focusable for TrashPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TrashPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let messages = Rc::new(ChatRegistry::global(cx).read(cx).trashed(cx));
|
||||
let count = messages.len();
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.p_3()
|
||||
.gap_3()
|
||||
.text_xs()
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Messages that could not be decrypted. New encryption keys may make them readable."),
|
||||
)
|
||||
.child(
|
||||
Button::new("trash-retry")
|
||||
.label("Try again")
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(count == 0)
|
||||
.on_click(|_, _window, cx| {
|
||||
ChatRegistry::global(cx).update(cx, |registry, cx| {
|
||||
registry.reprocess_trash(cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
.when(count == 0, |this| this.child(self.render_empty(cx)))
|
||||
.when(count > 0, |this| {
|
||||
this.child(
|
||||
uniform_list(
|
||||
"trash-list",
|
||||
count,
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.render_entries(range, messages.as_slice(), cx)
|
||||
}),
|
||||
)
|
||||
.min_h_0()
|
||||
.flex_1(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TrashPanel {
|
||||
fn render_entries(
|
||||
&self,
|
||||
range: std::ops::Range<usize>,
|
||||
messages: &[TrashedMessage],
|
||||
cx: &mut Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
messages
|
||||
.get(range)
|
||||
.map(|slice| {
|
||||
slice
|
||||
.iter()
|
||||
.map(|message| {
|
||||
// Keep a small gap between entries so they read as separate rows
|
||||
div()
|
||||
.pb_1p5()
|
||||
.child(self.render_entry(message, cx))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ gpui.workspace = true
|
||||
nostr.workspace = true
|
||||
instant.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
itertools.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
+99
-23
@@ -1,20 +1,18 @@
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const HEX_DIGITS: [u8; 16] = *b"0123456789abcdef";
|
||||
|
||||
pub trait EventExt {
|
||||
/// Id over the event's participants, stable across Rust versions.
|
||||
fn uniq_id(&self) -> u64;
|
||||
fn extract_public_keys(&self) -> Vec<PublicKey>;
|
||||
}
|
||||
|
||||
impl EventExt for Event {
|
||||
fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<PublicKey> = self.extract_public_keys();
|
||||
pubkeys.sort();
|
||||
pubkeys.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
pubkey_set_uniq_id(&self.extract_public_keys())
|
||||
}
|
||||
|
||||
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
||||
@@ -27,31 +25,62 @@ impl EventExt for Event {
|
||||
|
||||
impl EventExt for UnsignedEvent {
|
||||
fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<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()
|
||||
pubkey_set_uniq_id(&self.extract_public_keys())
|
||||
}
|
||||
|
||||
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
|
||||
public_keys.push(self.pubkey);
|
||||
|
||||
public_keys.into_iter().unique().sorted().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Digest over NUL-separated parts, so part boundaries stay unambiguous.
|
||||
fn parts_digest(parts: &[&str]) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
|
||||
for part in parts {
|
||||
hasher.update(part.as_bytes());
|
||||
hasher.update([0]);
|
||||
}
|
||||
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Stable id over parts: hex-encoded SHA-256 truncated to 128 bits.
|
||||
pub fn stable_id(parts: &[&str]) -> String {
|
||||
let digest = parts_digest(parts);
|
||||
|
||||
let mut id = String::with_capacity(32);
|
||||
for byte in &digest[..16] {
|
||||
id.push(HEX_DIGITS[usize::from(byte >> 4)] as char);
|
||||
id.push(HEX_DIGITS[usize::from(byte & 0x0f)] as char);
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
/// Stable 64-bit id over parts, for callers that key ids on `u64`.
|
||||
pub fn stable_id_u64(parts: &[&str]) -> u64 {
|
||||
let digest = parts_digest(parts);
|
||||
|
||||
let mut lead = [0u8; 8];
|
||||
for (slot, byte) in lead.iter_mut().zip(digest.iter()) {
|
||||
*slot = *byte;
|
||||
}
|
||||
|
||||
u64::from_be_bytes(lead)
|
||||
}
|
||||
|
||||
/// Hashes the sorted hex forms of the participant set.
|
||||
fn pubkey_set_uniq_id(public_keys: &[PublicKey]) -> u64 {
|
||||
let mut parts: Vec<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
|
||||
pub fn emojis_from_tags(tags: &Tags) -> Vec<(String, Url)> {
|
||||
let mut emojis: Vec<(String, Url)> = Vec::new();
|
||||
@@ -100,4 +129,51 @@ mod tests {
|
||||
Url::parse("https://example.com/soapbox.png").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
fn event_with_participants(public_key: PublicKey, participants: Vec<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 regex::Regex;
|
||||
|
||||
/// Extracts media URLs from a string and returns both
|
||||
/// the extracted URLs and the string with media URLs removed
|
||||
struct MediaExtractor {
|
||||
image_regex: Regex,
|
||||
}
|
||||
/// Matches image URLs with common image extensions
|
||||
static IMAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
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 {
|
||||
/// 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
|
||||
fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
|
||||
let mut urls = Vec::new();
|
||||
|
||||
// Extract image URLs
|
||||
for capture in self.image_regex.find_iter(text) {
|
||||
urls.push(capture.as_str().to_string().into());
|
||||
}
|
||||
|
||||
urls
|
||||
fn extract_media_urls(text: &str) -> Vec<SharedUri> {
|
||||
IMAGE_RE
|
||||
.find_iter(text)
|
||||
.map(|capture| capture.as_str().to_string().into())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Removes all media URLs from a string and returns the cleaned text
|
||||
fn remove_media_urls(&self, text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
fn remove_media_urls(text: &str) -> 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
|
||||
result = self.image_regex.replace_all(&result, "").to_string();
|
||||
for url in IMAGE_RE.find_iter(text) {
|
||||
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
|
||||
self.cleanup_text(&result)
|
||||
let gap = if last_end == 0 {
|
||||
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
|
||||
fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
|
||||
let urls = self.extract_media_urls(text);
|
||||
let cleaned_text = self.remove_media_urls(text);
|
||||
(urls, cleaned_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()
|
||||
fn extract_and_remove(text: &str) -> (Vec<SharedUri>, String) {
|
||||
(
|
||||
Self::extract_media_urls(text),
|
||||
Self::remove_media_urls(text),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience function for one-time extraction and removal
|
||||
pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.extract_and_remove(text)
|
||||
MediaExtractor::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> {
|
||||
/// The original text
|
||||
text: &'a str,
|
||||
/// Matches found
|
||||
matches: FindMatches<'a>,
|
||||
/// A pending match
|
||||
pending_match: Option<Match>,
|
||||
/// Last match end index
|
||||
last_match_end: usize,
|
||||
}
|
||||
|
||||
impl<'a> NostrParserIter<'a> {
|
||||
@@ -205,62 +196,65 @@ impl<'a> NostrParserIter<'a> {
|
||||
Self {
|
||||
text,
|
||||
matches: FindMatches::new(text),
|
||||
pending_match: None,
|
||||
last_match_end: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_match(&mut self, mat: Match) -> HandleMatch {
|
||||
// Update last match end
|
||||
self.last_match_end = mat.end;
|
||||
|
||||
fn handle_match(&self, mat: Match) -> Option<Token> {
|
||||
// Extract the matched string
|
||||
let data: &str = &self.text[mat.start..mat.end];
|
||||
|
||||
// Parse NIP-21 URI
|
||||
match Nip21::parse(data) {
|
||||
Ok(uri) => HandleMatch::Token(Token {
|
||||
Ok(uri) => Some(Token {
|
||||
value: uri,
|
||||
range: mat.start..mat.end,
|
||||
}),
|
||||
// If the nostr URI parsing is invalid, skip it
|
||||
Err(_) => HandleMatch::Recursion,
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for NostrParserIter<'a> {
|
||||
impl Iterator for NostrParserIter<'_> {
|
||||
type Item = Token;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
// Handle a pending match
|
||||
if let Some(pending_match) = self.pending_match.take() {
|
||||
return match self.handle_match(pending_match) {
|
||||
HandleMatch::Token(token) => Some(token),
|
||||
HandleMatch::Recursion => self.next(),
|
||||
};
|
||||
}
|
||||
// Iterative, not recursive: message text controls the match count
|
||||
loop {
|
||||
let mat = self.matches.next()?;
|
||||
|
||||
match self.matches.next() {
|
||||
Some(mat) => {
|
||||
// Skip any text before this match
|
||||
if mat.start > self.last_match_end {
|
||||
// Update pending match
|
||||
// This will be handled at next iteration, in `handle_match` method.
|
||||
self.pending_match = Some(mat);
|
||||
|
||||
// Skip the text before the match
|
||||
self.last_match_end = mat.start;
|
||||
return self.next();
|
||||
}
|
||||
|
||||
// Handle match
|
||||
match self.handle_match(mat) {
|
||||
HandleMatch::Token(token) => Some(token),
|
||||
HandleMatch::Recursion => self.next(),
|
||||
}
|
||||
if let Some(token) = self.handle_match(mat) {
|
||||
return Some(token);
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// A long run of scanner matches that fail `Nip21::parse`: the count is
|
||||
// attacker-controlled, so it must be consumed iteratively.
|
||||
#[test]
|
||||
fn skips_long_run_of_invalid_uris() {
|
||||
let content = "nostr:1q".repeat(50_000);
|
||||
|
||||
let tokens: Vec<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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ futures.workspace = true
|
||||
serde_json.workspace = true
|
||||
state = { path = "../state" }
|
||||
common = { path = "../common" }
|
||||
person = { path = "../person" }
|
||||
chat = { path = "../chat" }
|
||||
|
||||
gpui.workspace = true
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::LOCAL_KEYS;
|
||||
use crate::protocol::{
|
||||
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
|
||||
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;
|
||||
@@ -91,7 +91,8 @@ pub struct Group {
|
||||
membership: Membership,
|
||||
membership_events: Vec<Event>,
|
||||
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>,
|
||||
index: HashSet<EventId>,
|
||||
reactions: HashMap<EventId, Vec<Reaction>>,
|
||||
@@ -694,6 +695,15 @@ impl Group {
|
||||
}
|
||||
|
||||
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 {
|
||||
Kind::GroupMetadata => match GroupMetadata::parse(event) {
|
||||
Ok(metadata) => self.metadata = Some(metadata),
|
||||
@@ -724,7 +734,10 @@ impl Group {
|
||||
_ => 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 {
|
||||
|
||||
+43
-17
@@ -76,6 +76,8 @@ pub struct GroupsRegistry {
|
||||
groups: Vec<Entity<Group>>,
|
||||
previews: HashMap<GroupKey, Entity<Group>>,
|
||||
list: GroupList,
|
||||
/// The (created_at, id) of the last accepted group list event.
|
||||
list_stamp: Option<(Timestamp, EventId)>,
|
||||
browse: HashMap<SubscriptionId, RelayUrl>,
|
||||
routes: HashMap<SubscriptionId, GroupKey>,
|
||||
observers: HashMap<GroupKey, Subscription>,
|
||||
@@ -132,6 +134,7 @@ impl GroupsRegistry {
|
||||
groups: Vec::new(),
|
||||
previews: HashMap::new(),
|
||||
list: GroupList::default(),
|
||||
list_stamp: None,
|
||||
browse: HashMap::new(),
|
||||
routes: HashMap::new(),
|
||||
observers: HashMap::new(),
|
||||
@@ -455,6 +458,8 @@ impl GroupsRegistry {
|
||||
self.fork_authors.clear();
|
||||
self.syncing.clear();
|
||||
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 client = nostr.read(cx).client();
|
||||
@@ -488,24 +493,26 @@ impl GroupsRegistry {
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
// Show the cached groups before the relays answer.
|
||||
let task: Task<Result<GroupList, Error>> = cx.background_spawn(async move {
|
||||
let me = signer.get_public_key_async().await?;
|
||||
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
|
||||
let task: Task<Result<(GroupList, Option<Event>), Error>> =
|
||||
cx.background_spawn(async move {
|
||||
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() {
|
||||
Some(event) => {
|
||||
let list = GroupList::parse(&event)?;
|
||||
Ok(Self::hydrate_list(list, &signer, &me).await)
|
||||
match client.database().query(filter).await?.into_iter().next() {
|
||||
Some(event) => {
|
||||
let list =
|
||||
Self::hydrate_list(GroupList::parse(&event)?, &signer, &me).await;
|
||||
Ok((list, Some(event)))
|
||||
}
|
||||
None => Ok((GroupList::default(), None)),
|
||||
}
|
||||
None => Ok(GroupList::default()),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
self.tasks
|
||||
.push(cx.spawn(async move |this, cx| match task.await {
|
||||
Ok(list) => {
|
||||
Ok((list, event)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.merge(list, cx);
|
||||
this.merge(list, event.as_ref(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -538,7 +545,16 @@ impl GroupsRegistry {
|
||||
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.
|
||||
self.list
|
||||
.set_content(list.content().map(str::to_owned), list.content_readable());
|
||||
@@ -589,20 +605,21 @@ impl GroupsRegistry {
|
||||
};
|
||||
|
||||
if list.content().is_none() {
|
||||
self.merge(list, cx);
|
||||
self.merge(list, Some(&event), cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
self.merge(list, cx);
|
||||
self.merge(list, Some(&event), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
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 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
|
||||
// section this session could not read is carried verbatim.
|
||||
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 {
|
||||
Ok(output) if output.failed.is_empty() => {}
|
||||
Ok(output) => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use common::stable_id_u64;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
pub(crate) const TIMELINE_WINDOW: usize = 50;
|
||||
@@ -50,6 +49,15 @@ pub(crate) fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||
.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)]
|
||||
pub struct GroupId(String);
|
||||
|
||||
@@ -280,9 +288,7 @@ impl GroupKey {
|
||||
}
|
||||
|
||||
pub fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
self.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
stable_id_u64(&[self.relay.as_str(), self.id.as_str()])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1152,4 +1158,19 @@ mod tests {
|
||||
let malformed = Tag::custom("previous", ["nothex"]);
|
||||
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::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use common::stable_id_u64;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::{GroupId, GroupKey};
|
||||
@@ -25,10 +24,10 @@ pub(crate) fn subscription_id(key: &GroupKey) -> SubscriptionId {
|
||||
}
|
||||
|
||||
pub(crate) fn browse_subscription(relay: &RelayUrl) -> SubscriptionId {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
relay.hash(&mut hasher);
|
||||
|
||||
SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32))
|
||||
SubscriptionId::new(format!(
|
||||
"{BROWSE_PREFIX}{:08x}",
|
||||
stable_id_u64(&[relay.as_str()]) as u32
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn page_subscription(key: &GroupKey) -> SubscriptionId {
|
||||
|
||||
@@ -1065,7 +1065,8 @@ impl GroupPanel {
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.element(ix.into(), window, cx),
|
||||
// Key by event id: parse state is kept per element id and indices shift on prepend
|
||||
.element(SharedString::from(message.id.to_hex()).into(), window, cx),
|
||||
};
|
||||
|
||||
let admin = nostr
|
||||
@@ -1077,7 +1078,6 @@ impl GroupPanel {
|
||||
let pinned = group.pins().pins().contains(&Pin::Event(message.id));
|
||||
|
||||
message::render(
|
||||
ix,
|
||||
message,
|
||||
content,
|
||||
show_author,
|
||||
|
||||
@@ -35,7 +35,6 @@ pub(crate) struct Actions {
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
ix: usize,
|
||||
message: &Message,
|
||||
content: AnyElement,
|
||||
show_author: bool,
|
||||
@@ -60,7 +59,8 @@ pub(crate) fn render(
|
||||
nostr.read(cx).current_user(),
|
||||
);
|
||||
|
||||
let row = MessageRow::new(ix)
|
||||
// Key by event id: indices shift on prepend and would re-key descendants' state
|
||||
let row = MessageRow::new(SharedString::from(message.id.to_hex()))
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
@@ -337,7 +337,7 @@ fn replies(
|
||||
|
||||
let mut items = Vec::with_capacity(targets.len());
|
||||
|
||||
for (ix, id) in targets.iter().enumerate() {
|
||||
for id in targets.iter() {
|
||||
let context = entity.read_with(cx, |group, cx| {
|
||||
let message = group.message(*id)?;
|
||||
let author = persons.read(cx).person(&message.author, cx);
|
||||
@@ -356,7 +356,7 @@ fn replies(
|
||||
let panel = panel.clone();
|
||||
|
||||
items.push(
|
||||
ReplyPreview::new(ix, author, preview)
|
||||
ReplyPreview::new(SharedString::from(format!("reply-{id}")), author, preview)
|
||||
.on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update(cx, |panel, cx| {
|
||||
panel.scroll_to(&id, cx);
|
||||
|
||||
+103
-160
@@ -1,5 +1,5 @@
|
||||
use std::cell::Cell;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
@@ -19,14 +19,12 @@ use nostr_sdk::prelude::*;
|
||||
use person::Directory;
|
||||
use settings::{AppSettings, NIP4E_ROTATION_MAX_DAYS, NIP4E_ROTATION_MIN_DAYS};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{Announcement, NostrRegistry, UniversalSigner, credentials};
|
||||
use state::{Announcement, NostrRegistry, UniversalSigner};
|
||||
use storage::{SECONDS_PER_DAY, archive_key, get_keys, load_device_keys, set_keys};
|
||||
use ui::Avatar;
|
||||
|
||||
mod storage;
|
||||
|
||||
/// Keep the durable processed-request log bounded
|
||||
const PROCESSED_REQUESTS_LIMIT: usize = 256;
|
||||
/// Ignore key requests older than a day so stale events on relays cannot resurface
|
||||
const REQUEST_MAX_AGE: u64 = SECONDS_PER_DAY;
|
||||
|
||||
@@ -79,14 +77,14 @@ pub struct DeviceRegistry {
|
||||
app_keys: Option<Keys>,
|
||||
/// The key request currently awaiting user approval on this device
|
||||
pending_request: Option<PendingRequest>,
|
||||
/// Handled key-sync request ids, bounded and persisted
|
||||
processed_requests: VecDeque<EventId>,
|
||||
/// Whether an announcement has been made for this device
|
||||
pub(crate) announcement_existed: Arc<AtomicBool>,
|
||||
/// Whether the local key is stale against the current announcement.
|
||||
resync: Arc<AtomicBool>,
|
||||
/// Whether a key rotation is in flight, to coalesce overlapping requests
|
||||
rotating: bool,
|
||||
/// Whether a key approval is in flight, to guard against a double click
|
||||
approving: bool,
|
||||
/// Person directory for requesters shown in the approval dialog
|
||||
directory: Entity<Directory>,
|
||||
/// Relay notification listener
|
||||
@@ -110,7 +108,6 @@ impl DeviceRegistry {
|
||||
|
||||
/// Create a new device registry instance
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let settings = AppSettings::global(cx);
|
||||
|
||||
@@ -153,20 +150,14 @@ impl DeviceRegistry {
|
||||
}),
|
||||
);
|
||||
|
||||
cx.defer(move |cx| {
|
||||
let _ = entity.update(cx, |this, cx| {
|
||||
this.handle_notifications(cx);
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
signer,
|
||||
app_keys: None,
|
||||
pending_request: None,
|
||||
processed_requests: VecDeque::new(),
|
||||
announcement_existed: Arc::new(AtomicBool::new(false)),
|
||||
resync: Arc::new(AtomicBool::new(false)),
|
||||
rotating: false,
|
||||
approving: false,
|
||||
directory: Directory::new(cx),
|
||||
notification_listener: None,
|
||||
tasks: vec![],
|
||||
@@ -184,8 +175,6 @@ impl DeviceRegistry {
|
||||
let announcement_existed = self.announcement_existed.clone();
|
||||
let (tx, rx) = flume::bounded::<Event>(100);
|
||||
|
||||
let read = credentials::read_processed_requests(cx);
|
||||
|
||||
self.notification_listener = Some(cx.background_spawn(async move {
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events = HashSet::new();
|
||||
@@ -200,41 +189,35 @@ impl DeviceRegistry {
|
||||
continue;
|
||||
}
|
||||
|
||||
match event.kind {
|
||||
Kind::Custom(10044) if current_user == Some(event.pubkey) => {
|
||||
announcement_existed.store(true, Ordering::Relaxed);
|
||||
tx.send_async(event.into_owned()).await?;
|
||||
}
|
||||
Kind::Custom(4454) if current_user == Some(event.pubkey) => {
|
||||
tx.send_async(event.into_owned()).await?;
|
||||
}
|
||||
Kind::Custom(4455) if current_user == Some(event.pubkey) => {
|
||||
tx.send_async(event.into_owned()).await?;
|
||||
}
|
||||
_ => {}
|
||||
let relevant = matches!(
|
||||
event.kind,
|
||||
Kind::Custom(10044) | Kind::Custom(4454) | Kind::Custom(4455)
|
||||
) && current_user == Some(event.pubkey);
|
||||
|
||||
if !relevant {
|
||||
continue;
|
||||
}
|
||||
|
||||
if event.kind == Kind::Custom(10044) {
|
||||
announcement_existed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let event_id = event.id;
|
||||
// Dropping keeps the listener alive when the consumer is gone;
|
||||
// missed 4e events are re-synced on the next signer change
|
||||
if let Err(error) = tx.send_async(event.into_owned()).await {
|
||||
log::error!("Dropping device key event {event_id}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The stream only ends without being replaced when the relay source is
|
||||
// gone, after which no 4e event is processed anymore
|
||||
log::error!("Device key notification listener stopped");
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
// Hydrate the processed-request log before routing any events
|
||||
match read.await {
|
||||
Ok(bytes) => {
|
||||
let processed = bytes
|
||||
.map(|bytes| parse_processed_requests(&bytes))
|
||||
.unwrap_or_default();
|
||||
|
||||
this.update(cx, |this, _cx| {
|
||||
this.processed_requests = processed;
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => log::error!("Failed to read processed key-sync requests: {e}"),
|
||||
}
|
||||
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
match event.kind {
|
||||
Kind::Custom(10044) => {
|
||||
@@ -261,36 +244,11 @@ impl DeviceRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Record a handled key-sync request id, persisting a bounded log
|
||||
fn mark_request_processed(&mut self, event_id: EventId, cx: &mut Context<Self>) {
|
||||
if self.processed_requests.contains(&event_id) {
|
||||
return;
|
||||
}
|
||||
self.processed_requests.push_back(event_id);
|
||||
while self.processed_requests.len() > PROCESSED_REQUESTS_LIMIT {
|
||||
self.processed_requests.pop_front();
|
||||
}
|
||||
|
||||
let bytes: Vec<u8> = self
|
||||
.processed_requests
|
||||
.iter()
|
||||
.flat_map(|id| id.to_hex().into_bytes())
|
||||
.collect();
|
||||
let write = credentials::write_processed_requests(cx, &bytes);
|
||||
cx.background_spawn(async move {
|
||||
if let Err(e) = write.await {
|
||||
log::error!("Failed to persist processed key-sync requests: {e}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Withdraw the pending approval.
|
||||
fn resolve_pending_request(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(pending) = self.pending_request.take() else {
|
||||
if self.pending_request.take().is_none() {
|
||||
return;
|
||||
};
|
||||
self.mark_request_processed(pending.event.id, cx);
|
||||
}
|
||||
|
||||
if let Some(window) = cx.windows().first().copied() {
|
||||
window
|
||||
@@ -453,10 +411,16 @@ impl DeviceRegistry {
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result: Result<Keys, Error> = async {
|
||||
// Archive the current key before replacing it
|
||||
if let Ok(old) = get_keys(&client, &signer).await {
|
||||
archive_key(&client, &signer, &old.secret_key().to_secret_hex()).await?;
|
||||
}
|
||||
// Archive the current key before replacing it; a failed load or
|
||||
// archive aborts the rotation so the old key is never overwritten
|
||||
// without a record
|
||||
let old = get_keys(&client, &signer)
|
||||
.await
|
||||
.context("Failed to load the current encryption key before rotation")?;
|
||||
|
||||
archive_key(&client, &signer, &old.secret_key().to_secret_hex())
|
||||
.await
|
||||
.context("Failed to archive the current encryption key")?;
|
||||
|
||||
let created = this
|
||||
.update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))?
|
||||
@@ -504,6 +468,7 @@ impl DeviceRegistry {
|
||||
let elapsed = Timestamp::now()
|
||||
.as_secs()
|
||||
.saturating_sub(keys.created_at.as_secs());
|
||||
|
||||
if elapsed < u64::from(interval_days) * SECONDS_PER_DAY {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -530,7 +495,6 @@ impl DeviceRegistry {
|
||||
|
||||
let secret = keys.secret_key().to_secret_hex();
|
||||
let n = keys.public_key();
|
||||
let created_at = Timestamp::now();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Construct an announcement event
|
||||
@@ -540,7 +504,7 @@ impl DeviceRegistry {
|
||||
.await?;
|
||||
|
||||
// Persist the key locally before announcing it
|
||||
set_keys(&client, &signer, &secret, created_at).await?;
|
||||
set_keys(&client, &signer, &secret, Timestamp::now()).await?;
|
||||
|
||||
// Publish announcement
|
||||
client
|
||||
@@ -826,13 +790,21 @@ impl DeviceRegistry {
|
||||
|
||||
/// Approve requests for device keys from other devices
|
||||
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 client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let event = event.clone();
|
||||
let request_id = event.id;
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
// Get device keys
|
||||
let keys = get_keys(&client, &signer).await?;
|
||||
@@ -889,20 +861,41 @@ impl DeviceRegistry {
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
// Record the request as handled so it cannot resurface
|
||||
this.mark_request_processed(request_id, cx);
|
||||
// Allow future requests to surface again
|
||||
this.pending_request = None;
|
||||
let superseded = this.update(cx, |this, cx| {
|
||||
this.approving = false;
|
||||
cx.notify();
|
||||
// Keep a newer request's pending state and toast
|
||||
if this
|
||||
.pending_request
|
||||
.as_ref()
|
||||
.is_some_and(|pending| pending.event.id == approved_id)
|
||||
{
|
||||
this.pending_request = None;
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})?;
|
||||
|
||||
cx.update(|window, cx| {
|
||||
window.remove_notification::<DeviceNotification>(cx);
|
||||
})
|
||||
.ok();
|
||||
if !superseded {
|
||||
cx.update(|window, cx| {
|
||||
window.remove_notification::<DeviceNotification>(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.approving = false;
|
||||
cx.notify();
|
||||
|
||||
// Pushing a fresh toast replaces the wedged one whose
|
||||
// button stays disabled
|
||||
if let Some(pending) = this.pending_request.take() {
|
||||
this.show_approval(pending.event, pending.client_key, cx);
|
||||
}
|
||||
})?;
|
||||
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(e.to_string()).autohide(false),
|
||||
@@ -933,51 +926,27 @@ impl DeviceRegistry {
|
||||
return;
|
||||
};
|
||||
|
||||
let read = credentials::read_processed_requests(cx);
|
||||
match self.pending_request.take() {
|
||||
// A retry signs a new request id but keeps the same client key
|
||||
Some(mut pending) if pending.client_key == client_key => {
|
||||
pending.event = event;
|
||||
self.pending_request = Some(pending);
|
||||
}
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let processed = match read.await {
|
||||
Ok(bytes) => bytes
|
||||
.map(|bytes| parse_processed_requests(&bytes).contains(&event.id))
|
||||
.unwrap_or(false),
|
||||
Err(e) => {
|
||||
log::error!("Failed to read processed key-sync requests: {e}");
|
||||
false
|
||||
// A different client key is a remounted requester
|
||||
other => {
|
||||
if other.is_some()
|
||||
&& let Some(window) = cx.windows().first().copied()
|
||||
{
|
||||
window
|
||||
.update(cx, |_view, window, cx| {
|
||||
window.remove_notification::<DeviceNotification>(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
if processed || this.processed_requests.contains(&event.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match this.pending_request.take() {
|
||||
// A retry signs a new request id but keeps the same client key
|
||||
Some(mut pending) if pending.client_key == client_key => {
|
||||
this.mark_request_processed(event.id, cx);
|
||||
pending.event = event;
|
||||
this.pending_request = Some(pending);
|
||||
}
|
||||
|
||||
// A different client key is a remounted requester
|
||||
other => {
|
||||
if let Some(pending) = other {
|
||||
this.mark_request_processed(pending.event.id, cx);
|
||||
if let Some(window) = cx.windows().first().copied() {
|
||||
window
|
||||
.update(cx, |_view, window, cx| {
|
||||
window.remove_notification::<DeviceNotification>(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
this.show_approval(event, client_key, cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
self.show_approval(event, client_key, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build and surface the approval notification for a key request
|
||||
@@ -1103,19 +1072,6 @@ impl DeviceRegistry {
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.on_close({
|
||||
let entity = entity.clone();
|
||||
let request_id = event.id;
|
||||
|
||||
move |_window, cx| {
|
||||
// Closing without approving rejects the request, so it must not resurface
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.mark_request_processed(request_id, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.action(move |_this, _window, _cx| {
|
||||
let view = entity.clone();
|
||||
let event = event.clone();
|
||||
@@ -1127,9 +1083,10 @@ impl DeviceRegistry {
|
||||
.on_click({
|
||||
let loading = Rc::clone(&loading);
|
||||
move |_ev, window, cx| {
|
||||
// Set loading state to true
|
||||
loading.set(true);
|
||||
// Process to approve the request
|
||||
// The toast caches its render, so force a redraw
|
||||
// to disable the button while the approval is in flight
|
||||
window.refresh();
|
||||
view.update(cx, |this, cx| {
|
||||
this.approve(&event, window, cx);
|
||||
})
|
||||
@@ -1140,20 +1097,6 @@ impl DeviceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse concatenated hex event ids
|
||||
fn parse_processed_requests(bytes: &[u8]) -> VecDeque<EventId> {
|
||||
let Ok(hex) = std::str::from_utf8(bytes) else {
|
||||
return VecDeque::new();
|
||||
};
|
||||
|
||||
hex.as_bytes()
|
||||
.as_chunks::<64>()
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|chunk| EventId::from_hex(std::str::from_utf8(chunk).ok()?).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract the client public key from an event.
|
||||
fn client_pubkey_from_event(event: &Event) -> Option<PublicKey> {
|
||||
let key = event
|
||||
|
||||
@@ -20,7 +20,6 @@ serde_json.workspace = true
|
||||
futures.workspace = true
|
||||
hkdf.workspace = true
|
||||
sha2.workspace = true
|
||||
tokio = { version = "1", default-features = false, features = [ "rt" ] }
|
||||
|
||||
cdk.workspace = true
|
||||
cdk-redb.workspace = true
|
||||
|
||||
+73
-45
@@ -31,7 +31,6 @@ impl Global for GlobalWalletRegistry {}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WalletEvent {
|
||||
Updated,
|
||||
TokenCreated(String),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
@@ -55,6 +54,9 @@ pub struct HistoryEntry {
|
||||
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 {
|
||||
wallet: Option<keys::WalletData>,
|
||||
tokens: HashMap<EventId, TokenEvent>,
|
||||
@@ -69,6 +71,10 @@ pub struct WalletRegistry {
|
||||
pending_nutzaps: Vec<Event>,
|
||||
/// Nutzaps with a redemption job in flight
|
||||
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>>,
|
||||
wallets: HashMap<MintUrl, Wallet>,
|
||||
loading: bool,
|
||||
@@ -119,6 +125,8 @@ impl WalletRegistry {
|
||||
redeemed: HashSet::new(),
|
||||
pending_nutzaps: Vec::new(),
|
||||
redeeming: HashSet::new(),
|
||||
redeem_attempts: HashMap::new(),
|
||||
publish_failed: false,
|
||||
store: None,
|
||||
wallets: HashMap::new(),
|
||||
loading: false,
|
||||
@@ -184,34 +192,14 @@ impl WalletRegistry {
|
||||
);
|
||||
}
|
||||
|
||||
/// Pay a lightning invoice from the wallet.
|
||||
pub fn melt(&mut self, invoice: String, cx: &mut Context<Self>) {
|
||||
let Some(mint_url) = self.primary_mint(cx) else {
|
||||
/// 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.schedule_job(
|
||||
ops::Job::Melt {
|
||||
mint: mint_url,
|
||||
invoice,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Send ecash as a token string the recipient can paste anywhere.
|
||||
pub fn send_token(&mut self, amount: u64, cx: &mut Context<Self>) {
|
||||
let Some(mint_url) = self.primary_mint(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.schedule_job(
|
||||
ops::Job::SendToken {
|
||||
mint: mint_url,
|
||||
amount,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
self.quotes.retain(|quote| quote.quote_id != quote_id);
|
||||
self.emit_updated(cx);
|
||||
}
|
||||
|
||||
/// Send a NIP-61 nutzap to a peer, optionally nutzapping one of their events.
|
||||
@@ -378,6 +366,8 @@ impl WalletRegistry {
|
||||
self.redeemed.clear();
|
||||
self.pending_nutzaps.clear();
|
||||
self.redeeming.clear();
|
||||
self.redeem_attempts.clear();
|
||||
self.publish_failed = false;
|
||||
self.store = None;
|
||||
self.wallets.clear();
|
||||
self.loading = false;
|
||||
@@ -397,6 +387,7 @@ impl WalletRegistry {
|
||||
let signer = nostr.read(cx).signer().clone();
|
||||
|
||||
let (tx, rx) = flume::bounded::<Event>(100);
|
||||
let consumer_signer = signer.clone();
|
||||
|
||||
self.notification_listener = Some(cx.background_spawn(async move {
|
||||
const MAX_PROCESSED: usize = 1000;
|
||||
@@ -417,7 +408,7 @@ impl WalletRegistry {
|
||||
| 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)
|
||||
&& me.is_some_and(|me| event.tags.public_keys().any(|key| key == me));
|
||||
@@ -443,21 +434,22 @@ impl WalletRegistry {
|
||||
}));
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let me = consumer_signer.get_public_key_async().await.ok();
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.absorb(event, cx);
|
||||
this.absorb(event, me, cx);
|
||||
})
|
||||
.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 {
|
||||
Kind::CashuWallet => self.absorb_wallet(event, cx),
|
||||
Kind::CashuWalletUnspentProof => self.absorb_token(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),
|
||||
_ => {}
|
||||
}
|
||||
@@ -549,7 +541,15 @@ impl WalletRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
fn absorb_deletion(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
fn absorb_deletion(&mut self, event: Event, me: Option<PublicKey>, cx: &mut Context<Self>) {
|
||||
if me != Some(event.pubkey) {
|
||||
log::warn!(
|
||||
"ignoring kind 5 deletion {} from a foreign author",
|
||||
event.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut removed_any = false;
|
||||
for tag in event.tags.iter() {
|
||||
if tag.kind() != "e" {
|
||||
@@ -807,6 +807,9 @@ impl WalletRegistry {
|
||||
|
||||
self.job_running = true;
|
||||
|
||||
let job_handle = job.clone();
|
||||
let publish_failed = self.publish_failed;
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = Tokio::spawn_result(cx, async move {
|
||||
let ctx = ops::JobCtx {
|
||||
@@ -815,6 +818,7 @@ impl WalletRegistry {
|
||||
signer,
|
||||
client,
|
||||
tokens,
|
||||
publish_failed,
|
||||
};
|
||||
|
||||
job.run(ctx).await
|
||||
@@ -823,7 +827,7 @@ impl WalletRegistry {
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
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) {
|
||||
this.schedule_job(job, cx);
|
||||
@@ -833,18 +837,51 @@ impl WalletRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
fn apply_outcome(&mut self, result: Result<ops::Outcome, Error>, cx: &mut Context<Self>) {
|
||||
fn apply_outcome(
|
||||
&mut self,
|
||||
job: ops::Job,
|
||||
result: Result<ops::Outcome, Error>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let outcome = match result {
|
||||
Ok(outcome) => outcome,
|
||||
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);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match outcome {
|
||||
ops::Outcome::Idle => {}
|
||||
ops::Outcome::Idle => {
|
||||
if matches!(job, ops::Job::ClaimQuote { .. }) {
|
||||
self.emit_error("Invoice is not paid yet".to_string(), cx);
|
||||
}
|
||||
}
|
||||
ops::Outcome::Reconciled { updates } => {
|
||||
self.publish_failed = false;
|
||||
let changed = !updates.is_empty();
|
||||
|
||||
for update in updates {
|
||||
@@ -879,16 +916,6 @@ impl WalletRegistry {
|
||||
self.emit_updated(cx);
|
||||
log::info!("minted {amount} sats from quote {quote_id}");
|
||||
}
|
||||
ops::Outcome::Melted { update, amount } => {
|
||||
self.apply_token_update(update);
|
||||
self.emit_updated(cx);
|
||||
log::info!("melted {amount} sats");
|
||||
}
|
||||
ops::Outcome::Sent { update, token } => {
|
||||
self.apply_token_update(update);
|
||||
cx.emit(WalletEvent::TokenCreated(token));
|
||||
self.emit_updated(cx);
|
||||
}
|
||||
ops::Outcome::NutzapSent { update, amount } => {
|
||||
self.apply_token_update(update);
|
||||
self.emit_updated(cx);
|
||||
@@ -905,6 +932,7 @@ impl WalletRegistry {
|
||||
amount,
|
||||
} => {
|
||||
self.redeeming.remove(&nutzap_id);
|
||||
self.redeem_attempts.remove(&nutzap_id);
|
||||
self.redeemed.insert(nutzap_id);
|
||||
self.apply_token_update(update);
|
||||
self.emit_updated(cx);
|
||||
|
||||
+130
-117
@@ -8,8 +8,8 @@ use cdk::amount::SplitTarget;
|
||||
use cdk::mint_url::MintUrl;
|
||||
use cdk::nuts::nut00::KnownMethod;
|
||||
use cdk::nuts::{
|
||||
CurrencyUnit, Id, KeySetInfo, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs,
|
||||
SecretKey, SpendingConditions, State,
|
||||
CurrencyUnit, Id, KeySetInfo, MintQuoteState, PaymentMethod, Proof, Proofs, SecretKey,
|
||||
SpendingConditions, State,
|
||||
};
|
||||
use cdk::secret::Secret;
|
||||
use cdk::wallet::types::ProofInfo;
|
||||
@@ -35,14 +35,6 @@ pub(crate) enum Job {
|
||||
mint: MintUrl,
|
||||
quote_id: String,
|
||||
},
|
||||
Melt {
|
||||
mint: MintUrl,
|
||||
invoice: String,
|
||||
},
|
||||
SendToken {
|
||||
mint: MintUrl,
|
||||
amount: u64,
|
||||
},
|
||||
SendNutzap {
|
||||
recipient: PublicKey,
|
||||
amount: u64,
|
||||
@@ -117,67 +109,6 @@ impl Job {
|
||||
amount,
|
||||
})
|
||||
}
|
||||
Job::Melt { mint, invoice } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let quote = wallet
|
||||
.melt_quote(
|
||||
PaymentMethod::Known(KnownMethod::Bolt11),
|
||||
invoice,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let finalized = wallet
|
||||
.prepare_melt("e.id, HashMap::new())
|
||||
.await?
|
||||
.confirm()
|
||||
.await?;
|
||||
|
||||
if finalized.state() != MeltQuoteState::Paid {
|
||||
return Err(anyhow!("melt did not settle: {:?}", finalized.state()));
|
||||
}
|
||||
|
||||
let amount = u64::from(finalized.total_amount());
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::Out,
|
||||
amount,
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::Melted { update, amount })
|
||||
}
|
||||
Job::SendToken { mint, amount } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let prepared = wallet
|
||||
.prepare_send(Amount::from(amount), SendOptions::default())
|
||||
.await?;
|
||||
let token = prepared.confirm(None).await?.to_string();
|
||||
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::Out,
|
||||
amount,
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::Sent { update, token })
|
||||
}
|
||||
Job::SendNutzap {
|
||||
recipient,
|
||||
amount,
|
||||
@@ -330,14 +261,6 @@ pub(crate) enum Outcome {
|
||||
update: TokenUpdate,
|
||||
amount: u64,
|
||||
},
|
||||
Melted {
|
||||
update: TokenUpdate,
|
||||
amount: u64,
|
||||
},
|
||||
Sent {
|
||||
update: TokenUpdate,
|
||||
token: String,
|
||||
},
|
||||
NutzapSent {
|
||||
update: TokenUpdate,
|
||||
amount: u64,
|
||||
@@ -361,6 +284,8 @@ pub(crate) struct JobCtx<S> {
|
||||
pub(crate) client: Client,
|
||||
/// Relay token events at schedule time.
|
||||
pub(crate) tokens: HashMap<EventId, TokenEvent>,
|
||||
/// A previous job errored after possibly mutating the store
|
||||
pub(crate) publish_failed: bool,
|
||||
}
|
||||
|
||||
impl<S> JobCtx<S>
|
||||
@@ -426,35 +351,14 @@ where
|
||||
log::warn!("saga recovery failed for {mint_url}: {error:#}");
|
||||
}
|
||||
|
||||
let minted = wallet.mint_unissued_quotes().await?;
|
||||
if minted == Amount::ZERO {
|
||||
continue;
|
||||
}
|
||||
let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
|
||||
let states = vec![State::Pending, State::Reserved, State::PendingSpent];
|
||||
|
||||
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?;
|
||||
let in_flight = wallet.get_proofs_by_states(states).await?;
|
||||
let in_flight_ys = proof_ys(in_flight.iter())?;
|
||||
|
||||
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 added = Vec::new();
|
||||
let mut unseen = Vec::new();
|
||||
|
||||
for token in tokens
|
||||
.values()
|
||||
@@ -468,23 +372,104 @@ where
|
||||
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?;
|
||||
let removed = unspent
|
||||
.iter()
|
||||
.filter(|proof| match proof.y() {
|
||||
Ok(y) => !relay_ys.contains(&y),
|
||||
Err(_) => false,
|
||||
})
|
||||
.map(|proof| proof.y())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if unseen.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
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 })
|
||||
@@ -499,6 +484,34 @@ fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<Ev
|
||||
.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.
|
||||
async fn recipient_wallet_info<S>(
|
||||
ctx: &JobCtx<S>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::ops::Range;
|
||||
use std::rc::Rc;
|
||||
|
||||
use ::nip60::{HistoryEntry, WalletEvent, WalletRegistry};
|
||||
use ::nip60::{HistoryEntry, MintQuoteEntry, WalletEvent, WalletRegistry};
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
@@ -13,6 +13,7 @@ use gpui::{
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||
use gpui_kit::component::clipboard::Clipboard;
|
||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
|
||||
use gpui_kit::component::notification::Notification;
|
||||
@@ -51,7 +52,6 @@ impl WalletPanel {
|
||||
WalletEvent::Error(error) => {
|
||||
window.push_notification(Notification::error(error.clone()), cx);
|
||||
}
|
||||
WalletEvent::TokenCreated(_) => {}
|
||||
},
|
||||
));
|
||||
|
||||
@@ -108,6 +108,111 @@ impl WalletPanel {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_quotes(&self, quotes: Vec<MintQuoteEntry>, cx: &Context<Self>) -> Option<AnyElement> {
|
||||
if quotes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Pending invoices"),
|
||||
)
|
||||
.children(
|
||||
quotes
|
||||
.iter()
|
||||
.map(|quote| self.render_quote_entry(quote, cx)),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_quote_entry(&self, quote: &MintQuoteEntry, cx: &Context<Self>) -> impl IntoElement {
|
||||
let wallet = WalletRegistry::global(cx);
|
||||
let quote_id = quote.quote_id.clone();
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().success)
|
||||
.child(SharedString::from(format!("{} sats", quote.amount))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.truncate()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(quote.mint.to_string())),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new(format!("mint-quote-check-{quote_id}"))
|
||||
.label("Check")
|
||||
.tooltip("Check payment and claim")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click({
|
||||
let wallet = wallet.clone();
|
||||
let quote_id = quote_id.clone();
|
||||
move |_, _, cx| {
|
||||
wallet.update(cx, |registry, cx| {
|
||||
registry.claim_mint_quote(quote_id.clone(), cx)
|
||||
});
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Clipboard::new(format!("mint-quote-copy-{quote_id}"))
|
||||
.value(quote.request.clone())
|
||||
.tooltip("Copy invoice")
|
||||
.small(),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("mint-quote-dismiss-{quote_id}"))
|
||||
.icon(IconName::Close)
|
||||
.tooltip("Dismiss")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click({
|
||||
let wallet = wallet.clone();
|
||||
move |_, _, cx| {
|
||||
wallet.update(cx, |registry, cx| {
|
||||
registry.dismiss_mint_quote(quote_id.clone(), cx)
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis_middle()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(quote.request.clone())),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_history(
|
||||
&mut self,
|
||||
history: Vec<HistoryEntry>,
|
||||
@@ -748,6 +853,7 @@ impl Panel for WalletPanel {
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(self.name.clone())
|
||||
.child(ui::experimental_tag(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -762,7 +868,9 @@ impl Focusable for WalletPanel {
|
||||
|
||||
impl Render for WalletPanel {
|
||||
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()
|
||||
.size_full()
|
||||
@@ -771,6 +879,7 @@ impl Render for WalletPanel {
|
||||
.text_xs()
|
||||
.child(self.render_balance(cx))
|
||||
.child(self.render_actions())
|
||||
.children(self.render_quotes(quotes, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
|
||||
+45
-20
@@ -1,5 +1,5 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::RwLock;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::emojis_from_tags;
|
||||
@@ -64,7 +64,7 @@ enum Dispatch {
|
||||
#[derive(Debug)]
|
||||
pub struct PersonRegistry {
|
||||
/// Public keys with a metadata request in flight
|
||||
in_flight: RwLock<HashSet<PublicKey>>,
|
||||
in_flight: Arc<RwLock<HashSet<PublicKey>>>,
|
||||
/// Web of trust graph of the signed-in user
|
||||
wot: Entity<WebOfTrust>,
|
||||
sender: flume::Sender<PublicKey>,
|
||||
@@ -102,8 +102,12 @@ impl PersonRegistry {
|
||||
|
||||
let client3 = client.clone();
|
||||
let executor = cx.background_executor().clone();
|
||||
|
||||
let in_flight = Arc::new(RwLock::new(HashSet::new()));
|
||||
let in_flight_requests = Arc::clone(&in_flight);
|
||||
|
||||
tasks.push(cx.background_spawn(async move {
|
||||
Self::handle_requests(&client3, &metadata_rx, executor).await;
|
||||
Self::handle_requests(&client3, &metadata_rx, executor, &in_flight_requests).await;
|
||||
}));
|
||||
|
||||
tasks.push(cx.spawn(async move |this, cx| {
|
||||
@@ -140,7 +144,7 @@ impl PersonRegistry {
|
||||
});
|
||||
|
||||
Self {
|
||||
in_flight: RwLock::new(HashSet::new()),
|
||||
in_flight,
|
||||
wot,
|
||||
sender: metadata_tx,
|
||||
tasks,
|
||||
@@ -205,6 +209,7 @@ impl PersonRegistry {
|
||||
client: &Client,
|
||||
rx: &flume::Receiver<PublicKey>,
|
||||
executor: BackgroundExecutor,
|
||||
in_flight: &RwLock<HashSet<PublicKey>>,
|
||||
) {
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
@@ -216,34 +221,44 @@ impl PersonRegistry {
|
||||
result = rx.recv_async().fuse() => match result {
|
||||
Ok(public_key) => public_key,
|
||||
Err(_) => {
|
||||
if !batch.is_empty()
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
}
|
||||
Self::flush_batch(client, &mut batch, in_flight).await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
() = timer => {
|
||||
if !batch.is_empty()
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
}
|
||||
Self::flush_batch(client, &mut batch, in_flight).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
batch.insert(request);
|
||||
// Process the batch if it's full
|
||||
if batch.len() >= 20
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
if batch.len() >= 20 {
|
||||
Self::flush_batch(client, &mut batch, in_flight).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one metadata request for the batch, releasing its keys for retry on failure
|
||||
async fn flush_batch(
|
||||
client: &Client,
|
||||
batch: &mut HashSet<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
|
||||
fn get_emojis(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -334,11 +349,21 @@ impl PersonRegistry {
|
||||
|
||||
/// Allow future fetches for a key once its metadata has arrived
|
||||
fn release_in_flight(&self, public_key: &PublicKey) {
|
||||
let mut in_flight = match self.in_flight.write() {
|
||||
Self::release_keys(&self.in_flight, [*public_key]);
|
||||
}
|
||||
|
||||
/// Remove the given keys from the in-flight set
|
||||
fn release_keys(
|
||||
in_flight: &RwLock<HashSet<PublicKey>>,
|
||||
keys: impl IntoIterator<Item = PublicKey>,
|
||||
) {
|
||||
let mut in_flight = match in_flight.write() {
|
||||
Ok(in_flight) => in_flight,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
in_flight.remove(public_key);
|
||||
for public_key in keys {
|
||||
in_flight.remove(&public_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::Announcement;
|
||||
@@ -20,32 +17,6 @@ pub struct Person {
|
||||
messaging_relays: Vec<RelayUrl>,
|
||||
}
|
||||
|
||||
impl PartialEq for Person {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.public_key == other.public_key
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Person {}
|
||||
|
||||
impl PartialOrd for Person {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Person {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.name().cmp(&other.name())
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for Person {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.public_key.hash(state)
|
||||
}
|
||||
}
|
||||
|
||||
impl Person {
|
||||
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
||||
Self {
|
||||
|
||||
+150
-22
@@ -28,6 +28,14 @@ pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
|
||||
/// Built-in image shown behind chat and group panels
|
||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||
|
||||
fn default_onboarded() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_shadow() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The user's appearance preference, persisted with the rest of the settings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum AppearanceMode {
|
||||
@@ -78,6 +86,9 @@ pub struct ThemeSettings {
|
||||
pub radius: Option<f32>,
|
||||
/// Corner radius for large elements like dialogs; follows the OS theme when unset
|
||||
pub radius_lg: Option<f32>,
|
||||
/// Draw soft shadows on inputs, buttons and dialogs
|
||||
#[serde(default = "default_shadow")]
|
||||
pub shadow: bool,
|
||||
}
|
||||
|
||||
impl Default for ThemeSettings {
|
||||
@@ -87,6 +98,7 @@ impl Default for ThemeSettings {
|
||||
focus_ring: false,
|
||||
radius: None,
|
||||
radius_lg: None,
|
||||
shadow: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,6 +157,12 @@ pub struct Settings {
|
||||
/// Hide the notice that messages in public groups are not encrypted
|
||||
#[serde(default)]
|
||||
pub hide_group_notice: bool,
|
||||
/// Enable the NIP-60 wallet
|
||||
#[serde(default)]
|
||||
pub wallet: bool,
|
||||
/// Whether the first-run onboarding has been completed
|
||||
#[serde(default = "default_onboarded")]
|
||||
pub onboarded: bool,
|
||||
/// Tuning applied on top of the native OS theme
|
||||
#[serde(default)]
|
||||
pub theme: ThemeSettings,
|
||||
@@ -178,10 +196,12 @@ impl Default for Settings {
|
||||
appearance_mode: AppearanceMode::default(),
|
||||
hide_avatar: false,
|
||||
screening: true,
|
||||
wot_auto_trust: true,
|
||||
wot_auto_trust: false,
|
||||
nip4e: false,
|
||||
nip4e_rotation_days: None,
|
||||
hide_group_notice: false,
|
||||
wallet: false,
|
||||
onboarded: false,
|
||||
theme: ThemeSettings::default(),
|
||||
theme_light: None,
|
||||
theme_dark: None,
|
||||
@@ -203,6 +223,11 @@ impl Global for GlobalAppSettings {}
|
||||
pub struct AppSettings {
|
||||
/// 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
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
@@ -222,6 +247,9 @@ macro_rules! setting_accessors {
|
||||
inner.$field = value;
|
||||
cx.notify();
|
||||
});
|
||||
// Notify the handle too, so observers of AppSettings
|
||||
// react to the change
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -257,6 +285,8 @@ setting_accessors! {
|
||||
pub panel_background_path: Option<PathBuf>,
|
||||
pub theme_light: Option<String>,
|
||||
pub theme_dark: Option<String>,
|
||||
pub wallet: bool,
|
||||
pub onboarded: bool,
|
||||
}
|
||||
|
||||
setting_getters! {
|
||||
@@ -290,11 +320,16 @@ impl AppSettings {
|
||||
|
||||
// Run at the end of current cycle
|
||||
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 {
|
||||
inner,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
save_task: None,
|
||||
suppress_next_save: false,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
@@ -314,6 +349,8 @@ impl AppSettings {
|
||||
edit(&mut inner.theme);
|
||||
cx.notify();
|
||||
});
|
||||
// Notify the handle too, so observers of AppSettings react
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -326,6 +363,7 @@ impl AppSettings {
|
||||
let styled = Theme::global_mut(cx);
|
||||
styled.font_size = px(tuning.font_size);
|
||||
styled.focus_ring = tuning.focus_ring;
|
||||
styled.shadow = tuning.shadow;
|
||||
if let Some(radius) = tuning.radius {
|
||||
styled.radius = px(radius);
|
||||
}
|
||||
@@ -353,27 +391,46 @@ impl AppSettings {
|
||||
|
||||
/// Load settings
|
||||
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"))]
|
||||
{
|
||||
let path = config_dir().join(".settings");
|
||||
if let Ok(content) = smol::fs::read_to_string(&path).await {
|
||||
return Ok(serde_json::from_str(&content)?);
|
||||
match smol::fs::read_to_string(&path).await {
|
||||
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")]
|
||||
{
|
||||
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| {
|
||||
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
|
||||
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
|
||||
@@ -381,31 +438,63 @@ impl AppSettings {
|
||||
}
|
||||
|
||||
// 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.apply_theme(cx);
|
||||
cx.refresh_windows();
|
||||
})
|
||||
.ok();
|
||||
}) {
|
||||
log::error!("failed to apply loaded settings: {error:#}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Save settings
|
||||
pub fn save(&mut self, cx: &mut Context<Self>) {
|
||||
let settings = self.inner.read(cx);
|
||||
if let Ok(content) = serde_json::to_string(&settings) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cx.background_spawn(async move {
|
||||
let path = config_dir().join(".settings");
|
||||
smol::fs::write(&path, content).await.ok();
|
||||
})
|
||||
.detach();
|
||||
// Consume the failed-load suppression, so the next user change saves again
|
||||
if self.suppress_next_save {
|
||||
self.suppress_next_save = false;
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if let Err(e) = common::web_store::set(SETTINGS_KEY, &content) {
|
||||
log::warn!("failed to persist settings: {e}");
|
||||
let settings = self.inner.read(cx);
|
||||
let content = match serde_json::to_string(settings) {
|
||||
Ok(content) => content,
|
||||
Err(error) => {
|
||||
log::error!("failed to serialize settings: {error:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
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:#}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,6 +671,11 @@ fn persisted_content() -> Option<String> {
|
||||
common::web_store::get(SETTINGS_KEY).ok().flatten()
|
||||
}
|
||||
|
||||
/// Read the persisted onboarding flag synchronously.
|
||||
pub fn persisted_onboarded() -> bool {
|
||||
persisted_settings().is_some_and(|settings| settings.onboarded)
|
||||
}
|
||||
|
||||
pub fn persisted_appearance_mode() -> AppearanceMode {
|
||||
persisted_settings()
|
||||
.map(|settings| settings.appearance_mode)
|
||||
@@ -672,6 +766,40 @@ mod tests {
|
||||
assert_eq!(settings.theme, ThemeSettings::default());
|
||||
assert_eq!(settings.theme_light, None);
|
||||
assert_eq!(settings.theme_dark, None);
|
||||
|
||||
// Files written before onboarding and the wallet flag existed count
|
||||
// as onboarded with the wallet off
|
||||
assert!(settings.onboarded);
|
||||
assert!(!settings.wallet);
|
||||
}
|
||||
|
||||
/// Fresh installs start un-onboarded with optional features off, and the
|
||||
/// flags round-trip through serde
|
||||
#[test]
|
||||
fn onboarding_and_wallet_persist() {
|
||||
let settings = Settings::default();
|
||||
assert!(!settings.onboarded);
|
||||
assert!(!settings.wallet);
|
||||
assert!(!settings.wot_auto_trust);
|
||||
assert!(settings.theme.shadow);
|
||||
|
||||
let settings: Settings = serde_json::from_str(
|
||||
r#"{
|
||||
"appearance_mode": "Dark",
|
||||
"hide_avatar": false,
|
||||
"screening": true,
|
||||
"wot_auto_trust": true,
|
||||
"nip4e": false,
|
||||
"trusted_relays": [],
|
||||
"file_server": "https://nostr.download/",
|
||||
"onboarded": true,
|
||||
"wallet": true
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(settings.onboarded);
|
||||
assert!(settings.wallet);
|
||||
}
|
||||
|
||||
/// The theme selection round-trips through serde.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
@@ -30,7 +30,7 @@ impl SpeechRecognizer for ParakeetRecognizer {
|
||||
cx: &mut App,
|
||||
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
|
||||
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() {
|
||||
return Err(SpeechError::recognizer(anyhow!(
|
||||
"The speech model is still downloading. Try again when it finishes."
|
||||
@@ -79,7 +79,11 @@ impl RecognitionSession for ParakeetSession {
|
||||
}
|
||||
|
||||
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| {
|
||||
let result = receiver.recv_async().await;
|
||||
@@ -109,23 +113,50 @@ enum Command {
|
||||
Unload,
|
||||
}
|
||||
|
||||
/// A handle to the single inference worker.
|
||||
fn worker() -> &'static flume::Sender<Command> {
|
||||
static WORKER: OnceLock<flume::Sender<Command>> = OnceLock::new();
|
||||
fn worker_slot() -> &'static Mutex<Option<flume::Sender<Command>>> {
|
||||
static WORKER: OnceLock<Mutex<Option<flume::Sender<Command>>>> = OnceLock::new();
|
||||
WORKER.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
WORKER.get_or_init(|| {
|
||||
let (sender, receiver) = flume::unbounded::<Command>();
|
||||
/// A handle to the live worker, spawning a replacement after an idle exit.
|
||||
fn worker() -> Result<flume::Sender<Command>> {
|
||||
let mut slot = worker_slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("speech-parakeet".into())
|
||||
.spawn(move || run_worker(receiver));
|
||||
if slot.as_ref().is_some_and(|sender| sender.is_disconnected()) {
|
||||
*slot = None;
|
||||
}
|
||||
|
||||
if let Err(error) = spawned {
|
||||
log::error!("failed to spawn the speech worker: {error}");
|
||||
if let Some(sender) = slot.as_ref() {
|
||||
return Ok(sender.clone());
|
||||
}
|
||||
|
||||
let (sender, receiver) = flume::unbounded::<Command>();
|
||||
|
||||
match std::thread::Builder::new()
|
||||
.name("speech-parakeet".into())
|
||||
.spawn(move || run_worker(receiver))
|
||||
{
|
||||
Ok(_) => {
|
||||
*slot = Some(sender.clone());
|
||||
Ok(sender)
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("failed to spawn the speech worker: {error}");
|
||||
Err(anyhow!("Could not start the speech worker: {error}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>) {
|
||||
@@ -192,21 +223,11 @@ fn transcribe(cached: &mut Option<(PathBuf, ParakeetTDT)>, samples: Vec<f32>) ->
|
||||
.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.
|
||||
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> {
|
||||
|
||||
@@ -51,6 +51,7 @@ sqlite-wasm-vfs = "0.2"
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
||||
nostr-lmdb.workspace = true
|
||||
nostr-memory.workspace = true
|
||||
smol.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
rustls = "0.23"
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
use anyhow::Result;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use common::web_store;
|
||||
use gpui::{App, AppContext as _, Task};
|
||||
use gpui::{App, Task};
|
||||
|
||||
use crate::USER_KEYRING;
|
||||
|
||||
/// The storage key for the log of handled NIP-4e key-sync request ids
|
||||
const PROCESSED_REQUESTS_KEY: &str = "coop/nip4e/processed-requests";
|
||||
|
||||
/// Read the stored credential as `(username, secret)`
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) fn read(cx: &App) -> Task<Result<Option<(String, Vec<u8>)>>> {
|
||||
@@ -52,54 +49,3 @@ pub(crate) fn delete(cx: &App) -> Task<Result<()>> {
|
||||
Task::ready(web_store::remove(USER_KEYRING))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read named bytes from the platform keyring,
|
||||
/// falling back to browser storage on web where no keyring exists
|
||||
fn read_named(cx: &App, key: &str) -> Task<Result<Option<Vec<u8>>>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let key = key.to_owned();
|
||||
let read = cx.read_credentials(&key);
|
||||
cx.background_spawn(async move { Ok(read.await?.map(|(_, secret)| secret)) })
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = cx;
|
||||
let stored = web_store::get(key).map(|content| content.map(|s| s.into_bytes()));
|
||||
Task::ready(stored)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write named bytes to the platform keyring,
|
||||
/// falling back to browser storage on web where no keyring exists
|
||||
fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task<Result<()>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let key = key.to_owned();
|
||||
let secret = secret.to_vec();
|
||||
let write = cx.write_credentials(&key, "", &secret);
|
||||
cx.background_spawn(write)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = cx;
|
||||
let content = match std::str::from_utf8(secret) {
|
||||
Ok(content) => content,
|
||||
Err(e) => return Task::ready(Err(anyhow::anyhow!("secret is not UTF-8: {e}"))),
|
||||
};
|
||||
|
||||
Task::ready(web_store::set(key, content))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the log of handled key-sync request ids, as concatenated hex ids
|
||||
pub fn read_processed_requests(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
|
||||
read_named(cx, PROCESSED_REQUESTS_KEY)
|
||||
}
|
||||
|
||||
/// Persist the log of handled key-sync request ids, as concatenated hex ids
|
||||
pub fn write_processed_requests(cx: &App, ids: &[u8]) -> Task<Result<()>> {
|
||||
write_named(cx, PROCESSED_REQUESTS_KEY, ids)
|
||||
}
|
||||
|
||||
+12
-20
@@ -376,12 +376,6 @@ impl DownloadedFile {
|
||||
LocalFile::Url(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the file to the user's downloads (web)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn save(&self) -> Result<(), Error> {
|
||||
crate::web_file::download(&self.name, &self.mime, &self.data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Download and decrypt a file attachment to the user's device
|
||||
@@ -403,7 +397,7 @@ pub async fn download_and_decrypt_file(
|
||||
.clone()
|
||||
.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 {
|
||||
local,
|
||||
@@ -415,10 +409,11 @@ pub async fn download_and_decrypt_file(
|
||||
}
|
||||
|
||||
/// 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"))]
|
||||
{
|
||||
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)
|
||||
.and_then(|extensions| extensions.first())
|
||||
.copied()
|
||||
@@ -428,24 +423,21 @@ async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Er
|
||||
.join("coop-files")
|
||||
.join(format!("{digest}.{extension}"));
|
||||
|
||||
if smol::fs::metadata(&path).await.is_err() {
|
||||
let Some(parent) = path.parent() else {
|
||||
bail!("Invalid file path");
|
||||
};
|
||||
smol::fs::create_dir_all(parent).await?;
|
||||
let Some(parent) = path.parent() else {
|
||||
bail!("Invalid file path");
|
||||
};
|
||||
smol::fs::create_dir_all(parent).await?;
|
||||
|
||||
// Write under a temporary name first, so an interrupted download is never reused
|
||||
let partial = path.with_extension("download");
|
||||
smol::fs::write(&partial, data).await?;
|
||||
smol::fs::rename(&partial, &path).await?;
|
||||
}
|
||||
// Write under a temporary name first, so an interrupted download is never reused
|
||||
let partial = path.with_extension("download");
|
||||
smol::fs::write(&partial, data).await?;
|
||||
smol::fs::rename(&partial, &path).await?;
|
||||
|
||||
Ok(LocalFile::Path(path))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = name;
|
||||
let url = crate::web_file::blob_url(mime, data)?;
|
||||
Ok(LocalFile::Url(url))
|
||||
}
|
||||
|
||||
+35
-17
@@ -16,6 +16,8 @@ use nostr_connect::prelude::*;
|
||||
use nostr_gossip_memory::prelude::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use nostr_lmdb::prelude::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use nostr_memory::prelude::MemoryDatabase;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
mod blossom;
|
||||
@@ -89,8 +91,6 @@ pub struct NostrRegistry {
|
||||
signer: UniversalSigner,
|
||||
/// Current user's public key
|
||||
current_user: Option<PublicKey>,
|
||||
/// Whether the stored credential needs a passphrase to unlock
|
||||
passphrase_required: bool,
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
@@ -114,15 +114,27 @@ impl NostrRegistry {
|
||||
let signer = UniversalSigner::new(Keys::generate());
|
||||
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"))]
|
||||
let database = cx.foreground_executor().block_on(async move {
|
||||
NostrLmdb::open(config_dir().join("nostr"))
|
||||
.await
|
||||
.expect("Failed to initialize database")
|
||||
});
|
||||
let (database, database_error) = match cx
|
||||
.foreground_executor()
|
||||
.block_on(NostrLmdb::open(config_dir().join("nostr")))
|
||||
{
|
||||
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")]
|
||||
let database = web_database::take();
|
||||
let (database, database_error) = (web_database::take(), None::<String>);
|
||||
|
||||
// Construct the nostr client
|
||||
let client = ClientBuilder::default()
|
||||
@@ -141,6 +153,10 @@ impl NostrRegistry {
|
||||
// Connect to bootstrap relays once the registry has been returned to the app
|
||||
cx.defer(move |cx| {
|
||||
let _ = entity.update(cx, |this, cx| {
|
||||
if let Some(error) = database_error {
|
||||
cx.emit(StateEvent::error(error));
|
||||
}
|
||||
|
||||
this.connect_bootstrap_relays(cx);
|
||||
|
||||
if let Some(secret) = cli_key {
|
||||
@@ -157,7 +173,6 @@ impl NostrRegistry {
|
||||
client,
|
||||
signer,
|
||||
current_user: None,
|
||||
passphrase_required: false,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
@@ -191,7 +206,6 @@ impl NostrRegistry {
|
||||
this.update(cx, |this, cx| {
|
||||
this.signer.swap_inner(new_signer);
|
||||
this.current_user = Some(public_key);
|
||||
this.passphrase_required = false;
|
||||
|
||||
cx.emit(StateEvent::SignerChanged);
|
||||
cx.notify();
|
||||
@@ -273,8 +287,7 @@ impl NostrRegistry {
|
||||
this.set_signer(signer, cx);
|
||||
})?;
|
||||
} else if content.starts_with("ncryptsec1") {
|
||||
this.update(cx, |this, cx| {
|
||||
this.passphrase_required = true;
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::PassphraseRequired);
|
||||
})?;
|
||||
} else if content == "proxy" {
|
||||
@@ -445,8 +458,14 @@ impl NostrRegistry {
|
||||
};
|
||||
|
||||
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| {
|
||||
let signer = NostrConnect::builder(connect_uri, keys)
|
||||
@@ -468,9 +487,8 @@ impl NostrRegistry {
|
||||
/// Sign out and remove the stored credential
|
||||
pub fn sign_out(&mut self, cx: &mut Context<Self>) {
|
||||
self.current_user = None;
|
||||
self.passphrase_required = false;
|
||||
|
||||
credentials::delete(cx).detach_and_log_err(cx);
|
||||
|
||||
cx.emit(StateEvent::SignerChanged);
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
cx.notify();
|
||||
@@ -495,7 +513,6 @@ impl NostrRegistry {
|
||||
credentials::write(cx, "extension", b"extension").detach_and_log_err(cx);
|
||||
this.signer.swap_inner(signer);
|
||||
this.current_user = Some(public_key);
|
||||
this.passphrase_required = false;
|
||||
|
||||
cx.emit(StateEvent::SignerChanged);
|
||||
cx.notify();
|
||||
@@ -709,6 +726,7 @@ fn extract_master_key(credential: &str) -> (&str, Keys) {
|
||||
}
|
||||
|
||||
// Embed the master key in a bunker URI so reconnects reuse the same local key
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn with_master_key(uri: &str, keys: &Keys) -> String {
|
||||
let separator = if uri.contains('?') { '&' } else { '?' };
|
||||
let nsec = keys.secret_key().to_bech32().expect("infallible");
|
||||
|
||||
@@ -12,8 +12,5 @@ gpui.workspace = true
|
||||
gpui-base.workspace = true
|
||||
gpui-kit.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
web-time = "1.1"
|
||||
smallvec.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
uuid = "1.10"
|
||||
|
||||
+41
-7
@@ -1,3 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use gpui::{
|
||||
App, Div, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
|
||||
SharedString, StyleRefinement, Styled, Window, div, px,
|
||||
@@ -20,6 +23,10 @@ const MIN_FILLED: usize = 5;
|
||||
/// Seed for an avatar that has neither a picture nor a seed of its own.
|
||||
const FALLBACK_SEED: &str = "coop";
|
||||
|
||||
/// Memoized seed-derived patterns; PixelAvatar is recreated on every frame.
|
||||
static PATTERN_CACHE: LazyLock<Mutex<HashMap<String, AvatarPattern>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// An avatar for a user or other entity.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Avatar {
|
||||
@@ -93,7 +100,10 @@ impl RenderOnce for Avatar {
|
||||
.child(AvatarImage::new(url).size_full().rounded_full())
|
||||
.into_any_element(),
|
||||
None => PixelAvatar {
|
||||
seed: fnv1a(self.seed.unwrap_or_else(|| FALLBACK_SEED.into()).as_bytes()),
|
||||
seed: self
|
||||
.seed
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::new_static(FALLBACK_SEED)),
|
||||
side,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
@@ -114,7 +124,7 @@ impl RenderOnce for Avatar {
|
||||
/// A deterministic, offline pixel-art avatar.
|
||||
#[derive(IntoElement)]
|
||||
struct PixelAvatar {
|
||||
seed: u64,
|
||||
seed: SharedString,
|
||||
side: Pixels,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
@@ -128,11 +138,11 @@ impl Styled for PixelAvatar {
|
||||
impl RenderOnce for PixelAvatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let pattern = pattern(self.seed);
|
||||
let AvatarPattern { hash, grid } = cached_pattern(&self.seed);
|
||||
let mut cells = Vec::new();
|
||||
|
||||
let main = Hsla {
|
||||
h: self.seed as f32 / u64::MAX as f32,
|
||||
h: hash as f32 / u64::MAX as f32,
|
||||
s: 0.6,
|
||||
l: if theme.is_dark() { 0.6 } else { 0.45 },
|
||||
a: 1.,
|
||||
@@ -152,7 +162,7 @@ impl RenderOnce for PixelAvatar {
|
||||
|
||||
for row in 0..GRID_SIZE {
|
||||
for col in 0..GRID_SIZE {
|
||||
let value = pattern[row * GRID_SIZE + col];
|
||||
let value = grid[row * GRID_SIZE + col];
|
||||
if value != 0 {
|
||||
let color = if value == 2 { shade } else { main };
|
||||
cells.push(
|
||||
@@ -186,6 +196,32 @@ impl RenderOnce for PixelAvatar {
|
||||
}
|
||||
}
|
||||
|
||||
/// The seed-derived data a PixelAvatar renders, theme-dependent styling is applied per render.
|
||||
#[derive(Clone, Copy)]
|
||||
struct AvatarPattern {
|
||||
hash: u64,
|
||||
grid: [u8; GRID_SIZE * GRID_SIZE],
|
||||
}
|
||||
|
||||
/// `seed`'s hash and pixel grid, computing and caching them on first use.
|
||||
fn cached_pattern(seed: &str) -> AvatarPattern {
|
||||
let mut cache = PATTERN_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
|
||||
if let Some(cached) = cache.get(seed) {
|
||||
return *cached;
|
||||
}
|
||||
|
||||
let hash = fnv1a(seed.as_bytes());
|
||||
let value = AvatarPattern {
|
||||
hash,
|
||||
grid: pattern(hash),
|
||||
};
|
||||
cache.insert(seed.to_string(), value);
|
||||
value
|
||||
}
|
||||
|
||||
/// The rendered side length of an avatar at `size`.
|
||||
fn side_length(size: Size) -> Pixels {
|
||||
match size {
|
||||
@@ -202,8 +238,6 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
||||
let mut filled = 0usize;
|
||||
|
||||
// Only the inner rows and the inner left half are candidates; mirroring
|
||||
// then keeps the art within the same inset, leaving the outer ring empty.
|
||||
let art_rows = GRID_SIZE - 2 * MARGIN;
|
||||
let art_columns = GRID_SIZE / 2 - MARGIN;
|
||||
|
||||
|
||||
@@ -60,3 +60,57 @@ pub fn settings_block(
|
||||
)
|
||||
.child(control)
|
||||
}
|
||||
|
||||
/// An amber marker for features that are not ready for prime time.
|
||||
pub fn experimental_tag(cx: &App) -> Div {
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.px_1p5()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().warning.opacity(0.15))
|
||||
.text_color(cx.theme().warning)
|
||||
.text_xs()
|
||||
.font_medium()
|
||||
.child("Experimental")
|
||||
}
|
||||
|
||||
/// A [`settings_row`] whose label carries a tag, e.g. [`experimental_tag`].
|
||||
pub fn settings_row_tagged(
|
||||
label: impl Into<SharedString>,
|
||||
tag: Div,
|
||||
description: impl Into<SharedString>,
|
||||
control: impl IntoElement,
|
||||
cx: &App,
|
||||
) -> Div {
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_4()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.items_center()
|
||||
.child(div().text_sm().font_semibold().child(label.into()))
|
||||
.child(tag),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(description.into()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_40()
|
||||
.flex_shrink_0()
|
||||
.justify_end()
|
||||
.items_center()
|
||||
.child(control),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ type SharedClickListener = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
|
||||
type DropdownBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>;
|
||||
|
||||
/// Messages from the same author within this window are grouped into one run.
|
||||
pub const RUN_WINDOW_SECS: u64 = 300;
|
||||
pub(crate) const RUN_WINDOW_SECS: u64 = 300;
|
||||
|
||||
/// Whether a message starts a new run, given the previous message's author and unix time.
|
||||
pub fn opens_run<T: PartialEq>(previous: Option<(T, u64)>, current: (T, u64)) -> bool {
|
||||
@@ -758,14 +758,19 @@ impl RenderOnce for MessageActions {
|
||||
/// A message's text revealed word by word, paced by the estimated speech duration.
|
||||
#[derive(IntoElement)]
|
||||
pub struct RevealText {
|
||||
text: SharedString,
|
||||
words: Vec<SharedString>,
|
||||
duration: Duration,
|
||||
}
|
||||
|
||||
impl RevealText {
|
||||
/// Create text that reveals its words over `duration`.
|
||||
pub fn new(text: impl Into<SharedString>, duration: Duration) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
words: text
|
||||
.into()
|
||||
.split_whitespace()
|
||||
.map(SharedString::from)
|
||||
.collect(),
|
||||
duration,
|
||||
}
|
||||
}
|
||||
@@ -773,11 +778,7 @@ impl RevealText {
|
||||
|
||||
impl RenderOnce for RevealText {
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let words: Vec<SharedString> = self
|
||||
.text
|
||||
.split_whitespace()
|
||||
.map(SharedString::from)
|
||||
.collect();
|
||||
let words = self.words;
|
||||
let total = words.len().max(1) as f32;
|
||||
|
||||
div().flex().flex_wrap().gap_x_1().with_animation(
|
||||
|
||||
@@ -8,7 +8,7 @@ use person::Directory;
|
||||
|
||||
/// A span of the source content, by byte range, that renders as `text` instead.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InlineReplacement {
|
||||
pub(crate) struct InlineReplacement {
|
||||
pub range: Range<usize>,
|
||||
pub text: SharedString,
|
||||
/// When set, the span renders as this image (NIP-30 custom emoji)
|
||||
@@ -44,7 +44,7 @@ pub struct RenderedText {
|
||||
}
|
||||
|
||||
impl RenderedText {
|
||||
pub fn new(content: &str, replacements: &[InlineReplacement]) -> Self {
|
||||
pub(crate) fn new(content: &str, replacements: &[InlineReplacement]) -> Self {
|
||||
let mut text = String::with_capacity(content.len());
|
||||
let mut last = 0;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod import;
|
||||
pub mod join_group;
|
||||
pub mod new_chat;
|
||||
pub mod new_group;
|
||||
pub mod onboarding;
|
||||
pub mod profile;
|
||||
pub mod relays;
|
||||
pub mod restore;
|
||||
|
||||
@@ -60,6 +60,9 @@ impl NewChat {
|
||||
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.submit(window, cx)
|
||||
} else if matches!(event, InputEvent::Change) {
|
||||
// The contact list only re-filters when the dialog re-renders
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Div, Entity, Hsla, InteractiveElement, IntoElement,
|
||||
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
|
||||
div, px,
|
||||
};
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::dialog::{Confirm, DialogButtonProps, DialogFooter};
|
||||
use gpui_kit::component::input::{InputEvent, InputState, NumberInput};
|
||||
use gpui_kit::component::separator::Separator;
|
||||
use gpui_kit::component::switch::Switch;
|
||||
use gpui_kit::component::theme::{ThemeColor, try_parse_color};
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme, Theme, ThemeConfig, ThemeRegistry, WindowExt, h_flex, v_flex,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder;
|
||||
use rand::seq::IndexedRandom;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use ui::{experimental_tag, settings_block, settings_row, settings_row_tagged};
|
||||
|
||||
use super::settings::{FONT_SIZE_RANGE, RADIUS_RANGE};
|
||||
|
||||
/// Open the first-run onboarding dialog
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let view = cx.new(|cx| Onboarding::new(window, cx));
|
||||
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
this.width(px(640.))
|
||||
.close_button(false)
|
||||
.overlay_closable(false)
|
||||
.keyboard(false)
|
||||
.title("Welcome to Coop")
|
||||
.child(view.clone())
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.ok_text("Get started")
|
||||
.show_cancel(false)
|
||||
.on_ok(|_, _, cx| {
|
||||
AppSettings::update_onboarded(true, cx);
|
||||
// Returning true closes the dialog
|
||||
true
|
||||
}),
|
||||
)
|
||||
.footer(
|
||||
DialogFooter::new().child(
|
||||
Button::new("get-started")
|
||||
.label("Get started")
|
||||
.primary()
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
pub struct Onboarding {
|
||||
font_size_input: Entity<InputState>,
|
||||
radius_input: Entity<InputState>,
|
||||
light_themes: Vec<Rc<ThemeConfig>>,
|
||||
dark_themes: Vec<Rc<ThemeConfig>>,
|
||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||
}
|
||||
|
||||
impl Onboarding {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let theme = AppSettings::get_theme(cx);
|
||||
let (light_themes, dark_themes) = Self::sample_themes(cx);
|
||||
|
||||
let font_size_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.default_value(theme.font_size.to_string())
|
||||
.step(1.)
|
||||
.min(FONT_SIZE_RANGE.0 as f64)
|
||||
.max(FONT_SIZE_RANGE.1 as f64)
|
||||
});
|
||||
|
||||
let radius_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.default_value(
|
||||
theme
|
||||
.radius
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.placeholder("System")
|
||||
.step(1.)
|
||||
.min(RADIUS_RANGE.0 as f64)
|
||||
.max(RADIUS_RANGE.1 as f64)
|
||||
});
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push({
|
||||
let state = font_size_input.clone();
|
||||
cx.subscribe(&state, move |_, state, event: &InputEvent, cx| {
|
||||
if !matches!(event, InputEvent::Change) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(font_size) = Self::number_at(&state, FONT_SIZE_RANGE, cx) {
|
||||
AppSettings::update_theme(|theme| theme.font_size = font_size, cx);
|
||||
AppSettings::apply_theme_style(cx);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// One radius control tunes both radii so the whole UI stays coherent
|
||||
subscriptions.push({
|
||||
let state = radius_input.clone();
|
||||
cx.subscribe(&state, move |_, state, event: &InputEvent, cx| {
|
||||
if !matches!(event, InputEvent::Change) {
|
||||
return;
|
||||
}
|
||||
|
||||
let radius = Self::number_at(&state, RADIUS_RANGE, cx);
|
||||
AppSettings::update_theme(
|
||||
move |theme| {
|
||||
theme.radius = radius;
|
||||
theme.radius_lg = radius;
|
||||
},
|
||||
cx,
|
||||
);
|
||||
AppSettings::apply_theme_style(cx);
|
||||
})
|
||||
});
|
||||
|
||||
Self {
|
||||
font_size_input,
|
||||
radius_input,
|
||||
light_themes,
|
||||
dark_themes,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// The number in a theme input, clamped to the range, empty is None
|
||||
fn number_at(state: &Entity<InputState>, range: (f32, f32), cx: &App) -> Option<f32> {
|
||||
let (min, max) = range;
|
||||
state
|
||||
.read(cx)
|
||||
.value()
|
||||
.trim()
|
||||
.parse::<f32>()
|
||||
.ok()
|
||||
.map(|value| value.clamp(min, max))
|
||||
}
|
||||
|
||||
/// Three light and three dark bundled themes, sampled at random.
|
||||
fn sample_themes(cx: &App) -> (Vec<Rc<ThemeConfig>>, Vec<Rc<ThemeConfig>>) {
|
||||
let active = Theme::global(cx).theme_name().clone();
|
||||
let mut rng = rand::rng();
|
||||
|
||||
let (lights, darks): (Vec<_>, Vec<_>) = ThemeRegistry::global(cx)
|
||||
.sorted_themes()
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.partition(|theme| !theme.mode.is_dark());
|
||||
|
||||
let mut sample = |group: &[Rc<ThemeConfig>]| {
|
||||
let mut picks: Vec<_> = group.sample(&mut rng, 3).cloned().collect();
|
||||
let active_config = group.iter().find(|theme| theme.name == active);
|
||||
let already_sampled = picks.iter().any(|theme| theme.name == active);
|
||||
|
||||
if let (Some(config), Some(slot)) = (active_config, picks.first_mut())
|
||||
&& !already_sampled
|
||||
{
|
||||
*slot = config.clone();
|
||||
}
|
||||
|
||||
picks
|
||||
};
|
||||
|
||||
(sample(&lights), sample(&darks))
|
||||
}
|
||||
|
||||
/// The theme picker: a demo block per sampled bundled theme
|
||||
fn themes_view(&self, cx: &App) -> AnyElement {
|
||||
let active = Theme::global(cx).theme_name().clone();
|
||||
|
||||
settings_block(
|
||||
"Theme",
|
||||
"Customize UI colors. More in Settings.",
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(Self::theme_group("Light", &self.light_themes, &active, cx))
|
||||
.child(Self::theme_group("Dark", &self.dark_themes, &active, cx)),
|
||||
cx,
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn theme_group(
|
||||
label: &'static str,
|
||||
themes: &[Rc<ThemeConfig>],
|
||||
active: &SharedString,
|
||||
cx: &App,
|
||||
) -> Div {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(label),
|
||||
)
|
||||
.child(
|
||||
div().flex().flex_wrap().gap_2().children(
|
||||
themes
|
||||
.iter()
|
||||
.map(|config| Self::theme_card(config, active, cx)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// A card painting a mini app mockup in the theme's own colors
|
||||
fn theme_card(config: &Rc<ThemeConfig>, active: &SharedString, cx: &App) -> impl IntoElement {
|
||||
let selected = &config.name == active;
|
||||
let name = config.name.clone();
|
||||
let preview = Self::theme_preview(config);
|
||||
|
||||
let border = if selected {
|
||||
cx.theme().list_active_border
|
||||
} else {
|
||||
preview.border
|
||||
};
|
||||
|
||||
// Square block; the bigger inset keeps the mock window small inside it
|
||||
let block = div()
|
||||
.flex()
|
||||
.w_full()
|
||||
.aspect_square()
|
||||
.p_4()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.overflow_hidden()
|
||||
.bg(preview.background)
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
.hover(|this| this.border_color(cx.theme().border));
|
||||
|
||||
let block = if selected { block.border_2() } else { block };
|
||||
|
||||
v_flex()
|
||||
.id(SharedString::from(format!("theme-{name}")))
|
||||
.flex_1()
|
||||
.cursor_pointer()
|
||||
.child(
|
||||
block.child(
|
||||
h_flex()
|
||||
.size_full()
|
||||
.rounded(cx.theme().radius)
|
||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||
.overflow_hidden()
|
||||
.border_1()
|
||||
.border_color(preview.border)
|
||||
.bg(preview.window)
|
||||
.child(
|
||||
v_flex()
|
||||
.w_1_3()
|
||||
.h_full()
|
||||
.p_1()
|
||||
.justify_between()
|
||||
.bg(preview.sidebar)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(h_flex().gap_0p5().children((0..3).map(|_| {
|
||||
div().size_1().rounded_full().bg(preview.accent)
|
||||
})))
|
||||
.child(Self::demo_bar(preview.bar))
|
||||
.child(Self::demo_bar(preview.bar)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.size_2()
|
||||
.rounded_full()
|
||||
.bg(preview.bar.opacity(0.55)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.h_1()
|
||||
.rounded_full()
|
||||
.bg(preview.bar.opacity(0.55)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.p_1()
|
||||
.gap_1()
|
||||
.child(Self::demo_bar(preview.bar))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(div().flex_1().h_4().rounded_sm().bg(preview.block))
|
||||
.child(div().flex_1().h_4().rounded_sm().bg(preview.block)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.mt_1()
|
||||
.text_xs()
|
||||
.truncate()
|
||||
.text_color(if selected {
|
||||
cx.theme().foreground
|
||||
} else {
|
||||
cx.theme().muted_foreground
|
||||
})
|
||||
.child(name),
|
||||
)
|
||||
.on_click({
|
||||
let name = config.name.clone();
|
||||
move |_, _window, cx| AppSettings::select_theme(&name, cx)
|
||||
})
|
||||
}
|
||||
|
||||
/// A skeleton text bar for the demo block
|
||||
fn demo_bar(color: Hsla) -> Div {
|
||||
div().w_full().h_1().rounded_full().bg(color.opacity(0.55))
|
||||
}
|
||||
|
||||
/// The demo block colors resolved from a theme config.
|
||||
fn theme_preview(config: &ThemeConfig) -> ThemePreview {
|
||||
let defaults = if config.mode.is_dark() {
|
||||
ThemeColor::dark()
|
||||
} else {
|
||||
ThemeColor::light()
|
||||
};
|
||||
let colors = &config.colors;
|
||||
|
||||
let resolve = |value: Option<&SharedString>, fallback: Hsla| {
|
||||
value
|
||||
.and_then(|value| try_parse_color(value).ok())
|
||||
.unwrap_or(fallback)
|
||||
};
|
||||
|
||||
ThemePreview {
|
||||
background: resolve(colors.background.as_ref(), defaults.background),
|
||||
border: resolve(colors.border.as_ref(), defaults.border),
|
||||
window: resolve(colors.popover.as_ref(), defaults.popover),
|
||||
sidebar: resolve(colors.secondary.as_ref(), defaults.secondary),
|
||||
bar: resolve(colors.muted_foreground.as_ref(), defaults.muted_foreground),
|
||||
block: resolve(colors.secondary.as_ref(), defaults.secondary),
|
||||
accent: resolve(colors.primary.as_ref(), defaults.primary),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The colors of a theme's demo block
|
||||
struct ThemePreview {
|
||||
background: Hsla,
|
||||
border: Hsla,
|
||||
window: Hsla,
|
||||
sidebar: Hsla,
|
||||
bar: Hsla,
|
||||
block: Hsla,
|
||||
accent: Hsla,
|
||||
}
|
||||
|
||||
impl Render for Onboarding {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const INTRO: &str =
|
||||
"Pick how Coop works and looks. You can change all of this later in Preferences.";
|
||||
const WOT: &str = "Treat people followed by your contacts as known senders.";
|
||||
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const WALLET: &str = "Show the wallet and allow ecash payments.";
|
||||
const FONT_SIZE: &str = "The base font size for the interface.";
|
||||
const RADIUS: &str =
|
||||
"Corner radius for UI elements, including dialogs. Empty follows the theme.";
|
||||
const FOCUS_RING: &str = "Draw a ring around focused controls.";
|
||||
const SHADOW: &str = "Draw soft shadows on buttons, inputs and dialogs.";
|
||||
|
||||
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
|
||||
let nip4e = AppSettings::get_nip4e(cx);
|
||||
let theme = AppSettings::get_theme(cx);
|
||||
|
||||
let features = v_flex()
|
||||
.gap_3()
|
||||
.child(settings_row(
|
||||
"Web of Trust",
|
||||
WOT,
|
||||
Switch::new("onboarding-wot")
|
||||
.checked(wot_auto_trust)
|
||||
.on_click(move |checked, _window, cx| {
|
||||
AppSettings::update_wot_auto_trust(*checked, cx);
|
||||
}),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row_tagged(
|
||||
"Decoupling Encryption Key",
|
||||
experimental_tag(cx),
|
||||
NIP4E,
|
||||
Switch::new("onboarding-nip4e").checked(nip4e).on_click(
|
||||
move |checked, _window, cx| {
|
||||
AppSettings::update_nip4e(*checked, cx);
|
||||
},
|
||||
),
|
||||
cx,
|
||||
));
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let features = features.child(settings_row_tagged(
|
||||
"Wallet",
|
||||
experimental_tag(cx),
|
||||
WALLET,
|
||||
Switch::new("onboarding-wallet")
|
||||
.checked(AppSettings::get_wallet(cx))
|
||||
.on_click(move |checked, _window, cx| {
|
||||
AppSettings::update_wallet(*checked, cx);
|
||||
}),
|
||||
cx,
|
||||
));
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(INTRO),
|
||||
)
|
||||
.child(features)
|
||||
.child(Separator::horizontal())
|
||||
.child(self.themes_view(cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(settings_row(
|
||||
"Font Size",
|
||||
FONT_SIZE,
|
||||
NumberInput::new(&self.font_size_input).w_24(),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"Corner Radius",
|
||||
RADIUS,
|
||||
NumberInput::new(&self.radius_input).w_24(),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"Focus Ring",
|
||||
FOCUS_RING,
|
||||
Switch::new("onboarding-focus-ring")
|
||||
.checked(theme.focus_ring)
|
||||
.on_click(move |checked, _window, cx| {
|
||||
AppSettings::update_theme(|theme| theme.focus_ring = *checked, cx);
|
||||
AppSettings::apply_theme_style(cx);
|
||||
}),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"Shadows",
|
||||
SHADOW,
|
||||
Switch::new("onboarding-shadow")
|
||||
.checked(theme.shadow)
|
||||
.on_click(move |checked, _window, cx| {
|
||||
AppSettings::update_theme(|theme| theme.shadow = *checked, cx);
|
||||
AppSettings::apply_theme_style(cx);
|
||||
}),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,17 @@ use chat::{ChatRegistry, Room, RoomKind};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Task, Window, div, retain_all,
|
||||
Styled, Subscription, Task, Window, div, retain_all,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::clipboard::Clipboard;
|
||||
use gpui_kit::component::dock::DockPlacement;
|
||||
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
|
||||
use settings::AppSettings;
|
||||
@@ -51,13 +52,19 @@ struct ProfileDialog {
|
||||
uploading: bool,
|
||||
/// Updating state
|
||||
updating: bool,
|
||||
/// Error message
|
||||
error: Option<SharedString>,
|
||||
/// Tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// Keeps the avatar preview live while the URL is typed
|
||||
_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl ProfileDialog {
|
||||
fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let directory = Directory::new(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let is_current_user = nostr
|
||||
.read(cx)
|
||||
.current_user()
|
||||
@@ -75,7 +82,17 @@ impl ProfileDialog {
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
|
||||
let directory = Directory::new(cx);
|
||||
let subscription = cx.subscribe_in(
|
||||
&avatar_input,
|
||||
window,
|
||||
|_this, _avatar_input, event, _window, cx| {
|
||||
if matches!(event, InputEvent::Change) {
|
||||
// The avatar preview only re-renders when the dialog does
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
directory.update(cx, |directory, cx| {
|
||||
directory.track([public_key], cx);
|
||||
});
|
||||
@@ -91,7 +108,9 @@ impl ProfileDialog {
|
||||
website_input,
|
||||
uploading: false,
|
||||
updating: false,
|
||||
error: None,
|
||||
tasks: vec![],
|
||||
_subscription: Some(subscription),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +209,26 @@ impl ProfileDialog {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
E: Into<SharedString>,
|
||||
{
|
||||
self.error = Some(error.into());
|
||||
cx.notify();
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||
|
||||
// Clear the error message after a delay
|
||||
this.update(cx, |this, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set the metadata for the current user
|
||||
fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -218,19 +257,51 @@ impl ProfileDialog {
|
||||
let bio = self.bio_input.read(cx).value();
|
||||
let website = self.website_input.read(cx).value();
|
||||
|
||||
// Validate both URLs before publishing anything so a partial profile is never sent
|
||||
let avatar_url = if avatar.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match Url::from_str(&avatar) {
|
||||
Ok(url) => Some(url),
|
||||
Err(_) => {
|
||||
self.set_error(
|
||||
"Avatar URL is invalid. Include the full address, e.g. https://example.com/avatar.jpg",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let website_url = if website.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match Url::from_str(&website) {
|
||||
Ok(url) => Some(url),
|
||||
Err(_) => {
|
||||
self.set_error(
|
||||
"Website URL is invalid. Include the full address, e.g. https://example.com",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Construct the new metadata
|
||||
let mut new_metadata = old_metadata
|
||||
.display_name(name.as_ref())
|
||||
.name(name.as_ref())
|
||||
.about(bio.as_ref());
|
||||
|
||||
// Verify the avatar URL before adding it
|
||||
if let Ok(url) = Url::from_str(&avatar) {
|
||||
// An empty input keeps the saved value, a valid URL replaces it
|
||||
if let Some(url) = avatar_url {
|
||||
new_metadata = new_metadata.picture(url);
|
||||
}
|
||||
|
||||
// Verify the website URL before adding it
|
||||
if let Ok(url) = Url::from_str(&website) {
|
||||
if let Some(url) = website_url {
|
||||
new_metadata = new_metadata.website(url);
|
||||
}
|
||||
|
||||
@@ -429,6 +500,15 @@ impl Render for ProfileDialog {
|
||||
this.child(field("Web of Trust", score, "Not loaded yet", cx))
|
||||
})
|
||||
})
|
||||
.when_some(self.error.as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
|
||||
@@ -10,6 +10,7 @@ use gpui::{
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::spinner::Spinner;
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
||||
use instant::Duration;
|
||||
@@ -23,11 +24,23 @@ pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<
|
||||
cx.new(|cx| Screening::new(public_key, window, cx))
|
||||
}
|
||||
|
||||
/// Outcome of verifying the person's NIP-05 address
|
||||
enum IdentifierStatus {
|
||||
/// The check is still running
|
||||
Pending,
|
||||
/// The address matches the person's public key
|
||||
Verified,
|
||||
/// The address does not match the person's public key
|
||||
Mismatched,
|
||||
/// The check could not run, e.g. the network is unreachable
|
||||
Failed,
|
||||
}
|
||||
|
||||
pub struct Screening {
|
||||
/// Public Key of the person being screened.
|
||||
public_key: PublicKey,
|
||||
/// Whether the person's address is verified.
|
||||
verified: bool,
|
||||
/// Outcome of the NIP-05 address verification
|
||||
identifier_status: IdentifierStatus,
|
||||
/// Whether the person is followed by current user.
|
||||
followed: bool,
|
||||
/// Last time the person was active.
|
||||
@@ -49,9 +62,8 @@ impl Screening {
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
|
||||
subscriptions.push(cx.on_release_in(window, move |this, _window, _cx| {
|
||||
this.tasks.clear();
|
||||
window.close_all_dialogs(cx);
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| {
|
||||
@@ -71,7 +83,7 @@ impl Screening {
|
||||
|
||||
Self {
|
||||
public_key,
|
||||
verified: false,
|
||||
identifier_status: IdentifierStatus::Pending,
|
||||
followed: false,
|
||||
last_active: None,
|
||||
mutual_contacts: vec![],
|
||||
@@ -221,10 +233,17 @@ impl Screening {
|
||||
cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await.unwrap_or(false);
|
||||
let result = task.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.verified = result;
|
||||
this.identifier_status = match result {
|
||||
Ok(true) => IdentifierStatus::Verified,
|
||||
Ok(false) => IdentifierStatus::Mismatched,
|
||||
Err(error) => {
|
||||
log::error!("failed to verify NIP-05 address: {error:#}");
|
||||
IdentifierStatus::Failed
|
||||
}
|
||||
};
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
@@ -270,15 +289,27 @@ impl Screening {
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |_, cx| {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
window.close_dialog(cx);
|
||||
window.push_notification("Report submitted successfully", cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
self.tasks
|
||||
.push(cx.spawn_in(window, async move |_, cx| match task.await {
|
||||
Ok(_) => {
|
||||
cx.update(|window, cx| {
|
||||
window.close_dialog(cx);
|
||||
window.push_notification("Report submitted successfully", cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("failed to submit report: {error:#}");
|
||||
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn mutual_contacts(
|
||||
@@ -341,6 +372,9 @@ impl Render for Screening {
|
||||
const NO_MUTUAL: &str = "You don't have any mutual contacts.";
|
||||
const NIP05_MATCH: &str = "The address matches the user's public key.";
|
||||
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
|
||||
const NIP05_PENDING: &str = "Checking the address…";
|
||||
const NIP05_FAILED: &str =
|
||||
"The address could not be verified, the network may be unavailable.";
|
||||
const NO_NIP05: &str = "This person has not set up their friendly address";
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
@@ -349,6 +383,20 @@ impl Render for Screening {
|
||||
|
||||
let last_active = self.last_active.map(|_| true);
|
||||
|
||||
let (identifier_badge, identifier_message) = match self.identifier_status {
|
||||
IdentifierStatus::Pending => (None, NIP05_PENDING),
|
||||
IdentifierStatus::Verified => (Some(true), NIP05_MATCH),
|
||||
IdentifierStatus::Mismatched => (Some(false), NIP05_NOT_MATCH),
|
||||
IdentifierStatus::Failed => (Some(false), NIP05_FAILED),
|
||||
};
|
||||
|
||||
// Without an address the check never runs
|
||||
let (identifier_badge, identifier_message) = if self.address(cx).is_some() {
|
||||
(identifier_badge, identifier_message)
|
||||
} else {
|
||||
(Some(false), NO_NIP05)
|
||||
};
|
||||
|
||||
// Pull from the web of trust once loaded, else use the database fallback
|
||||
let mutual_contacts = {
|
||||
let wot = persons.read(cx).wot().read(cx);
|
||||
@@ -491,7 +539,7 @@ impl Render for Screening {
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(Some(self.verified), cx))
|
||||
.child(status_badge(identifier_badge, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_xs()
|
||||
@@ -508,17 +556,7 @@ impl Render for Screening {
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child({
|
||||
if self.address(cx).is_some() {
|
||||
if self.verified {
|
||||
SharedString::from(NIP05_MATCH)
|
||||
} else {
|
||||
SharedString::from(NIP05_NOT_MATCH)
|
||||
}
|
||||
} else {
|
||||
SharedString::from(NO_NIP05)
|
||||
}
|
||||
}),
|
||||
.child(SharedString::from(identifier_message)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,11 +7,13 @@ use gpui::PathPromptOptions;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui::Task;
|
||||
use gpui::http_client::Url;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled,
|
||||
Subscription, Window, div,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
|
||||
use gpui_kit::component::kbd::Kbd;
|
||||
@@ -28,8 +30,8 @@ use speech::model::{self as speech_model};
|
||||
use ui::{settings_block, settings_row};
|
||||
|
||||
/// Bounds for the theme number inputs
|
||||
const FONT_SIZE_RANGE: (f32, f32) = (10., 32.);
|
||||
const RADIUS_RANGE: (f32, f32) = (0., 24.);
|
||||
pub(super) const FONT_SIZE_RANGE: (f32, f32) = (10., 32.);
|
||||
pub(super) const RADIUS_RANGE: (f32, f32) = (0., 24.);
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
||||
cx.new(|cx| Preferences::new(window, cx))
|
||||
@@ -95,7 +97,7 @@ impl Preferences {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…"));
|
||||
|
||||
let subscriptions = vec![
|
||||
let mut subscriptions = vec![
|
||||
Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| {
|
||||
if let Some(font_size) = value {
|
||||
AppSettings::update_theme(|theme| theme.font_size = font_size, cx);
|
||||
@@ -112,6 +114,12 @@ impl Preferences {
|
||||
}),
|
||||
];
|
||||
|
||||
// Keep the switches in sync with the settings they edit
|
||||
subscriptions.push({
|
||||
let settings = AppSettings::global(cx);
|
||||
cx.observe(&settings, |_, _, cx| cx.notify())
|
||||
});
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _speech_poll = cx.spawn_in(window, async move |this, cx| {
|
||||
let timer = Duration::from_millis(500);
|
||||
@@ -210,35 +218,53 @@ impl Preferences {
|
||||
fn render_wallet(
|
||||
&mut self,
|
||||
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))] cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
) -> Option<AnyElement> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
return div().into_any_element();
|
||||
return None;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
const WALLET: &str = "Cashu mint for your NIP-60 wallet. Set up creates the wallet, Add extends it with another mint.";
|
||||
let enabled = AppSettings::get_wallet(cx);
|
||||
let has_wallet = nip60::WalletRegistry::global(cx).read(cx).has_wallet();
|
||||
|
||||
settings_block(
|
||||
"Wallet",
|
||||
WALLET,
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(div().flex_1().child(Input::new(&self.mint_input)))
|
||||
.child(
|
||||
Button::new("setup-mint")
|
||||
.label(if has_wallet { "Add" } else { "Set up" })
|
||||
.ghost()
|
||||
.size_8()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.setup_mint(window, cx);
|
||||
})),
|
||||
),
|
||||
cx,
|
||||
Some(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(settings_row(
|
||||
"Wallet",
|
||||
"Show the wallet and allow ecash payments.",
|
||||
Switch::new("wallet").checked(enabled).on_click(
|
||||
move |checked, _window, cx| {
|
||||
AppSettings::update_wallet(*checked, cx);
|
||||
},
|
||||
),
|
||||
cx,
|
||||
))
|
||||
.when(enabled, |this| {
|
||||
this.child(settings_block(
|
||||
"Mint",
|
||||
WALLET,
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(div().flex_1().child(Input::new(&self.mint_input)))
|
||||
.child(
|
||||
Button::new("setup-mint")
|
||||
.label(if has_wallet { "Add" } else { "Set up" })
|
||||
.ghost()
|
||||
.size_8()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.setup_mint(window, cx);
|
||||
})),
|
||||
),
|
||||
cx,
|
||||
))
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,6 +484,7 @@ impl Render for Preferences {
|
||||
const RADIUS_LG: &str =
|
||||
"Corner radius for large elements like dialogs. Empty follows the OS theme.";
|
||||
const FOCUS_RING: &str = "Draw a ring around focused controls.";
|
||||
const SHADOW: &str = "Draw soft shadows on buttons, inputs and dialogs.";
|
||||
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
||||
const NIP4E_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it.";
|
||||
const BACKGROUND: &str = "Show an image behind chat and group panels.";
|
||||
@@ -566,14 +593,22 @@ impl Render for Preferences {
|
||||
FOCUS_RING,
|
||||
Switch::new("focus-ring")
|
||||
.checked(theme.focus_ring)
|
||||
.on_click(move |_, _window, cx| {
|
||||
AppSettings::update_theme(
|
||||
|theme| theme.focus_ring = !theme.focus_ring,
|
||||
cx,
|
||||
);
|
||||
.on_click(move |checked, _window, cx| {
|
||||
AppSettings::update_theme(|theme| theme.focus_ring = *checked, cx);
|
||||
AppSettings::apply_theme_style(cx);
|
||||
}),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"Shadows",
|
||||
SHADOW,
|
||||
Switch::new("shadow").checked(theme.shadow).on_click(
|
||||
move |checked, _window, cx| {
|
||||
AppSettings::update_theme(|theme| theme.shadow = *checked, cx);
|
||||
AppSettings::apply_theme_style(cx);
|
||||
},
|
||||
),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.child(Separator::horizontal())
|
||||
@@ -623,32 +658,39 @@ impl Render for Preferences {
|
||||
)),
|
||||
)
|
||||
.child(Separator::horizontal())
|
||||
.child(settings_row(
|
||||
"Decoupling Encryption Key",
|
||||
NIP4E,
|
||||
Switch::new("nip4e")
|
||||
.checked(nip4e)
|
||||
.on_click(move |_, _window, cx| {
|
||||
AppSettings::update_nip4e(!nip4e, cx);
|
||||
}),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"Rotate Encryption Key",
|
||||
NIP4E_ROTATION,
|
||||
Switch::new("nip4e_rotation")
|
||||
.checked(nip4e_rotation_days.is_some())
|
||||
.on_click(move |_, _window, cx| {
|
||||
let enabled = AppSettings::get_nip4e_rotation_days(cx).is_some();
|
||||
let days = if enabled {
|
||||
None
|
||||
} else {
|
||||
Some(NIP4E_ROTATION_DEFAULT_DAYS)
|
||||
};
|
||||
AppSettings::update_nip4e_rotation_days(days, cx);
|
||||
}),
|
||||
cx,
|
||||
))
|
||||
.child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(div().text_sm().font_semibold().child("Experimental"))
|
||||
.child(settings_row(
|
||||
"Decoupling Encryption Key",
|
||||
NIP4E,
|
||||
Switch::new("nip4e")
|
||||
.checked(nip4e)
|
||||
.on_click(move |_, _window, cx| {
|
||||
AppSettings::update_nip4e(!nip4e, cx);
|
||||
}),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"Rotate Encryption Key",
|
||||
NIP4E_ROTATION,
|
||||
Switch::new("nip4e_rotation")
|
||||
.checked(nip4e_rotation_days.is_some())
|
||||
.on_click(move |_, _window, cx| {
|
||||
let enabled = AppSettings::get_nip4e_rotation_days(cx).is_some();
|
||||
let days = if enabled {
|
||||
None
|
||||
} else {
|
||||
Some(NIP4E_ROTATION_DEFAULT_DAYS)
|
||||
};
|
||||
AppSettings::update_nip4e_rotation_days(days, cx);
|
||||
}),
|
||||
cx,
|
||||
))
|
||||
.when_some(self.render_wallet(cx), |this, wallet| this.child(wallet)),
|
||||
)
|
||||
.child(Separator::horizontal())
|
||||
.child(self.render_speech(cx))
|
||||
.child(self.render_dictation_shortcut(window, cx))
|
||||
@@ -670,7 +712,5 @@ impl Render for Preferences {
|
||||
),
|
||||
cx,
|
||||
))
|
||||
.child(Separator::horizontal())
|
||||
.child(self.render_wallet(cx))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{App, Pixels, WeakEntity, Window, px};
|
||||
use gpui_kit::component::dock::{
|
||||
DockArea, DockPlacement, InsertTarget, NodeId, PaneRef, PanelHandle, PanelId,
|
||||
};
|
||||
use chat_ui::ChatPanel;
|
||||
use gpui::{App, Entity, Pixels, SharedString, WeakEntity, Window, px};
|
||||
use gpui_base::dock::PanelView;
|
||||
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle, PanelId};
|
||||
use nip29::GroupKey;
|
||||
use nip29_ui::GroupPanel;
|
||||
|
||||
mod invalid_panel;
|
||||
mod skin;
|
||||
@@ -14,39 +16,83 @@ pub use skin::CoopDockSkin;
|
||||
|
||||
/// The fixed height of the tab bar, which doubles as the window title bar.
|
||||
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
|
||||
/// Dock placements.
|
||||
const DOCK_PLACEMENTS: [DockPlacement; 4] = [
|
||||
DockPlacement::Center,
|
||||
DockPlacement::Left,
|
||||
DockPlacement::Right,
|
||||
DockPlacement::Bottom,
|
||||
];
|
||||
|
||||
/// The panel in any region of `area whose id is `key.
|
||||
fn find_panel(area: &DockArea, key: PanelId, _cx: &App) -> Option<(PanelId, NodeId, usize)> {
|
||||
let placements = [
|
||||
DockPlacement::Center,
|
||||
DockPlacement::Left,
|
||||
DockPlacement::Right,
|
||||
DockPlacement::Bottom,
|
||||
];
|
||||
/// What makes a panel the same panel across opens.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
enum PanelKey {
|
||||
/// A panel of which there should be one, keyed by its `panel_name`.
|
||||
Named(&'static str),
|
||||
/// A chat panel, keyed by the id of the room it displays.
|
||||
Room(SharedString),
|
||||
}
|
||||
|
||||
for placement in placements {
|
||||
/// The key a panel handle carries, or `None` when it cannot be read off the panel.
|
||||
fn panel_key(panel: &PanelHandle, cx: &App) -> Option<PanelKey> {
|
||||
let view = panel.panel();
|
||||
if let Some(chat) = view.as_any().downcast_ref::<Entity<ChatPanel>>() {
|
||||
return Some(PanelKey::Room(chat.read(cx).room_id().clone()));
|
||||
}
|
||||
if view.as_any().downcast_ref::<Entity<GroupPanel>>().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(PanelKey::Named(view.panel_name(cx)))
|
||||
}
|
||||
|
||||
/// The key of a panel docked in `area`, recovered through the handle base holds.
|
||||
fn docked_panel_key(view: &Arc<dyn PanelView>, cx: &App) -> Option<PanelKey> {
|
||||
let handle = PanelHandle::of(view)?;
|
||||
panel_key(handle, cx)
|
||||
}
|
||||
|
||||
/// The docked panel whose key is `key`, and the dock it sits in.
|
||||
fn find_panel(area: &DockArea, key: &PanelKey, cx: &App) -> Option<(PanelId, DockPlacement)> {
|
||||
for placement in DOCK_PLACEMENTS {
|
||||
let Some(tree) = area.layout(placement) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for id in tree.panels() {
|
||||
if id != key {
|
||||
continue;
|
||||
}
|
||||
|
||||
let node = tree.find_panel_node(id)?;
|
||||
let PaneRef::Tabs { panels, .. } = tree.find_node(node)?.kind() else {
|
||||
let Some(view) = area.panel(id) else {
|
||||
continue;
|
||||
};
|
||||
let ix = panels.iter().position(|panel| *panel == key)?;
|
||||
return Some((id, node, ix));
|
||||
if docked_panel_key(view, cx).is_some_and(|found| &found == key) {
|
||||
return Some((id, placement));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Add `panel to the area, or move it to the end of its existing tab group.
|
||||
/// Bring the docked panel `id` on screen.
|
||||
pub fn focus_panel(
|
||||
area: &mut DockArea,
|
||||
id: PanelId,
|
||||
window: &mut Window,
|
||||
cx: &mut gpui::Context<DockArea>,
|
||||
) {
|
||||
let Some(placement) = DOCK_PLACEMENTS.into_iter().find(|placement| {
|
||||
area.layout(*placement)
|
||||
.is_some_and(|tree| tree.panels().any(|panel| panel == id))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// The center region has no open state, and `toggle_dock` would ignore it.
|
||||
if placement != DockPlacement::Center && !area.is_dock_open(placement) {
|
||||
area.toggle_dock(placement, window, cx);
|
||||
}
|
||||
area.select_panel(id, window, cx);
|
||||
}
|
||||
|
||||
/// Add `panel` to the area, or focus the panel already showing the same thing.
|
||||
pub fn add_panel(
|
||||
area: &mut DockArea,
|
||||
panel: PanelHandle,
|
||||
@@ -54,24 +100,44 @@ pub fn add_panel(
|
||||
window: &mut Window,
|
||||
cx: &mut gpui::Context<DockArea>,
|
||||
) {
|
||||
let key = panel.panel_id(cx);
|
||||
if let Some((id, node, ix)) = find_panel(area, key, cx) {
|
||||
area.move_panel(
|
||||
id,
|
||||
InsertTarget::Tabs {
|
||||
node,
|
||||
ix: Some(ix),
|
||||
activate: true,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let Some(key) = panel_key(&panel, cx) else {
|
||||
area.add_panel_view(Arc::new(panel), placement, None, window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some((id, _)) = find_panel(area, &key, cx) {
|
||||
focus_panel(area, id, window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
area.add_panel_view(Arc::new(panel), placement, None, window, cx);
|
||||
}
|
||||
|
||||
/// Close the docked chat panel showing `room_id`, if there is one.
|
||||
pub fn close_room_panel(
|
||||
area: &mut DockArea,
|
||||
room_id: &SharedString,
|
||||
window: &mut Window,
|
||||
cx: &mut gpui::Context<DockArea>,
|
||||
) {
|
||||
let key = PanelKey::Room(room_id.clone());
|
||||
|
||||
let Some((id, _)) = find_panel(area, &key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(view) = area.panel(id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(chat) = view.as_any().downcast_ref::<Entity<ChatPanel>>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let chat = chat.clone();
|
||||
area.remove_panel(chat, window, cx);
|
||||
}
|
||||
|
||||
/// Add a panel to a dock reached through a weak handle.
|
||||
pub fn add_panel_to(
|
||||
dock: &WeakEntity<DockArea>,
|
||||
@@ -86,3 +152,25 @@ pub fn add_panel_to(
|
||||
log::warn!("Failed to add panel to dock: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a group panel to a dock, or focus the panel already showing that group.
|
||||
///
|
||||
/// Group panels hide their group, so the workspace records which panel it
|
||||
/// docked per key; without that record, reopening a group stacks another tab.
|
||||
pub fn add_group_panel_to(
|
||||
dock: &WeakEntity<DockArea>,
|
||||
group_key: GroupKey,
|
||||
panel: PanelHandle,
|
||||
placement: DockPlacement,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(root) = window.root::<crate::Workspace>().flatten() else {
|
||||
add_panel_to(dock, panel, placement, window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
root.update(cx, |this, cx| {
|
||||
this.add_group_panel_to_dock(group_key, panel, placement, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ use super::tab_panel::CoopTabGroupSkin;
|
||||
/// State the skin shares with its per-container renderers.
|
||||
pub(crate) struct SkinShared {
|
||||
area: WeakEntity<DockArea>,
|
||||
toggle_button_visible: Cell<bool>,
|
||||
/// The dock whose resize handle is being dragged, if any. Only one can be.
|
||||
resizing_dock: Cell<Option<DockPlacement>>,
|
||||
}
|
||||
@@ -32,10 +31,6 @@ impl SkinShared {
|
||||
&self.area
|
||||
}
|
||||
|
||||
pub(crate) fn is_toggle_button_visible(&self) -> bool {
|
||||
self.toggle_button_visible.get()
|
||||
}
|
||||
|
||||
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
|
||||
&self.resizing_dock
|
||||
}
|
||||
@@ -52,7 +47,6 @@ impl CoopDockSkin {
|
||||
Rc::new(Self {
|
||||
shared: Rc::new(SkinShared {
|
||||
area: cx.weak_entity(),
|
||||
toggle_button_visible: Cell::new(true),
|
||||
resizing_dock: Cell::new(None),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -209,7 +209,7 @@ impl CoopTabGroupSkin {
|
||||
group: &TabGroupContext,
|
||||
cx: &mut App,
|
||||
) -> Option<Button> {
|
||||
if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
|
||||
if group.is_zoomed() {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
+69
-19
@@ -1,8 +1,9 @@
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ::settings::{AppSettings, AppearanceMode};
|
||||
use ::settings::{AppSettings, AppearanceMode, persisted_onboarded};
|
||||
use anyhow::Error;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use auto_update::AutoUpdater;
|
||||
@@ -17,7 +18,7 @@ use gpui_kit::component::button::Button;
|
||||
use gpui_kit::component::command::{
|
||||
Command as CommandPalette, CommandGroup, CommandItem, CommandState,
|
||||
};
|
||||
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
||||
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle, PanelId};
|
||||
use gpui_kit::component::notification::{Notification, NotificationType};
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme, IndexPath, Theme, ThemeConfig, ThemeRegistry, WindowExt, v_flex,
|
||||
@@ -30,7 +31,9 @@ use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, StateEvent};
|
||||
|
||||
use crate::dialogs::restore::RestoreEncryption;
|
||||
use crate::dialogs::{import, join_group, new_chat, new_group, profile, relays, settings};
|
||||
use crate::dialogs::{
|
||||
import, join_group, new_chat, new_group, onboarding, profile, relays, settings,
|
||||
};
|
||||
use crate::panels::{backup, browse, contact_list, greeter, inbox};
|
||||
use crate::sidebar::Sidebar;
|
||||
|
||||
@@ -49,7 +52,7 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
|
||||
cx.new(|cx| Workspace::new(window, cx))
|
||||
}
|
||||
|
||||
struct DeviceNotifcation;
|
||||
struct DeviceNotification;
|
||||
struct MsgRelayNotification;
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
@@ -78,6 +81,9 @@ enum Command {
|
||||
pub struct Workspace {
|
||||
dock: Entity<DockArea>,
|
||||
sidebar: Entity<Sidebar>,
|
||||
/// Group panels docked by group key, so reopening a group focuses its panel.
|
||||
/// Group panels hide their group, so the dock cannot tell them apart.
|
||||
group_panels: HashMap<GroupKey, PanelId>,
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// Event subscriptions
|
||||
@@ -94,6 +100,7 @@ impl Workspace {
|
||||
let skin = dock::CoopDockSkin::new(cx);
|
||||
gpui_kit::component::dock::DockArea::new("coop", None, window, cx).with_renderer(skin)
|
||||
});
|
||||
|
||||
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
||||
let left_sidebar = sidebar.clone();
|
||||
|
||||
@@ -107,10 +114,18 @@ impl Workspace {
|
||||
}
|
||||
}));
|
||||
|
||||
let login_nostr = nostr.clone();
|
||||
subscriptions.push(
|
||||
// Subscribe to the nostr events
|
||||
cx.subscribe_in(&nostr, window, move |_, _, event, window, cx| match event {
|
||||
StateEvent::SignerChanged => window.close_all_dialogs(cx),
|
||||
StateEvent::SignerChanged => {
|
||||
window.close_all_dialogs(cx);
|
||||
|
||||
// Show the first-run onboarding once the user is signed in
|
||||
if !persisted_onboarded() && login_nostr.read(cx).current_user().is_some() {
|
||||
onboarding::open(window, cx);
|
||||
}
|
||||
}
|
||||
StateEvent::PassphraseRequired => import::open_unlock(window, cx),
|
||||
_ => {}
|
||||
}),
|
||||
@@ -126,7 +141,7 @@ impl Workspace {
|
||||
one shown there before approving.";
|
||||
|
||||
let note = Notification::new()
|
||||
.id::<DeviceNotifcation>()
|
||||
.id::<DeviceNotification>()
|
||||
.autohide(false)
|
||||
.title(format!("Wait for approval · {code}"))
|
||||
.message(MSG)
|
||||
@@ -140,7 +155,7 @@ impl Workspace {
|
||||
from the settings.";
|
||||
|
||||
let note = Notification::new()
|
||||
.id::<DeviceNotifcation>()
|
||||
.id::<DeviceNotification>()
|
||||
.message(MSG)
|
||||
.with_type(NotificationType::Info)
|
||||
.action(|_this, _window, _cx| {
|
||||
@@ -150,7 +165,7 @@ impl Workspace {
|
||||
device.update(cx, |this, cx| {
|
||||
this.get_announcement(cx);
|
||||
});
|
||||
window.remove_notification::<DeviceNotifcation>(cx);
|
||||
window.remove_notification::<DeviceNotification>(cx);
|
||||
},
|
||||
)
|
||||
});
|
||||
@@ -159,7 +174,7 @@ impl Workspace {
|
||||
}
|
||||
DeviceEvent::Set => {
|
||||
let note = Notification::new()
|
||||
.id::<DeviceNotifcation>()
|
||||
.id::<DeviceNotification>()
|
||||
.message("Encryption Key has been set")
|
||||
.with_type(NotificationType::Success);
|
||||
|
||||
@@ -216,14 +231,6 @@ impl Workspace {
|
||||
::nip60::WalletEvent::Error(error) => {
|
||||
window.push_notification(Notification::error(error.clone()), cx);
|
||||
}
|
||||
::nip60::WalletEvent::TokenCreated(_) => {
|
||||
window.push_notification(
|
||||
Notification::new()
|
||||
.message("Cashu token created, copy it from the wallet panel")
|
||||
.with_type(NotificationType::Success),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
::nip60::WalletEvent::Updated => {}
|
||||
},
|
||||
));
|
||||
@@ -265,11 +272,16 @@ impl Workspace {
|
||||
this.set_dock_size(DockPlacement::Left, SIDEBAR_WIDTH, window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
|
||||
if !persisted_onboarded() && nostr.read(cx).current_user().is_some() {
|
||||
onboarding::open(window, cx);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
dock,
|
||||
sidebar,
|
||||
group_panels: HashMap::new(),
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -304,6 +316,43 @@ impl Workspace {
|
||||
});
|
||||
}
|
||||
|
||||
fn add_group_panel_to_dock(
|
||||
&mut self,
|
||||
group_key: GroupKey,
|
||||
panel: PanelHandle,
|
||||
placement: DockPlacement,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let panel_id = panel.panel_id(cx);
|
||||
let docked = self.group_panels.get(&group_key).copied();
|
||||
|
||||
let mut added = false;
|
||||
let mut focused = false;
|
||||
|
||||
self.dock.update(cx, |area, cx| {
|
||||
if let Some(id) = docked
|
||||
&& area.panel(id).is_some()
|
||||
{
|
||||
crate::dock::focus_panel(area, id, window, cx);
|
||||
focused = true;
|
||||
return;
|
||||
}
|
||||
crate::dock::add_panel(area, panel, placement, window, cx);
|
||||
added = area.panel(panel_id).is_some();
|
||||
});
|
||||
|
||||
if focused {
|
||||
return;
|
||||
}
|
||||
|
||||
if added {
|
||||
self.group_panels.insert(group_key, panel_id);
|
||||
} else {
|
||||
self.group_panels.remove(&group_key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle command events
|
||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match command {
|
||||
@@ -343,9 +392,10 @@ impl Workspace {
|
||||
let panel = browse::init(self.dock.downgrade(), window, cx);
|
||||
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
|
||||
}
|
||||
Command::ShowWallet => {
|
||||
Command::ShowWallet =>
|
||||
{
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if AppSettings::get_wallet(cx) {
|
||||
let panel = nip60_ui::wallet::init(window, cx);
|
||||
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Task, Window, div,
|
||||
@@ -9,6 +10,7 @@ use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||
use gpui_kit::component::input::{Input, InputState};
|
||||
use gpui_kit::component::separator::Separator;
|
||||
use gpui_kit::component::{ActiveTheme, v_flex};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::USER_KEYRING;
|
||||
|
||||
@@ -23,14 +25,12 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<BackupPanel> {
|
||||
pub struct BackupPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Public key input
|
||||
npub_input: Entity<InputState>,
|
||||
|
||||
/// Secret key input
|
||||
nsec_input: Entity<InputState>,
|
||||
|
||||
/// Background tasks
|
||||
/// Error message
|
||||
error: Option<SharedString>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ impl BackupPanel {
|
||||
focus_handle: cx.focus_handle(),
|
||||
npub_input,
|
||||
nsec_input,
|
||||
error: None,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
@@ -57,20 +58,58 @@ impl BackupPanel {
|
||||
let keyring = cx.read_credentials(USER_KEYRING);
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
if let Some((_, secret)) = keyring.await? {
|
||||
let secret = SecretKey::from_slice(&secret)?;
|
||||
let keys = Keys::new(secret);
|
||||
let result: Result<(), Error> = async {
|
||||
let Some((_, secret)) = keyring.await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let keys = Keys::new(SecretKey::from_slice(&secret)?);
|
||||
|
||||
// Bech32 encoding of a valid key cannot fail today, but handle it instead of panicking
|
||||
let npub = keys.public_key().to_bech32()?;
|
||||
let nsec = keys.secret_key().to_bech32()?;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.npub_input.update(cx, |this, cx| {
|
||||
this.set_value(keys.public_key().to_bech32().unwrap(), window, cx);
|
||||
this.set_value(npub.as_str(), window, cx);
|
||||
});
|
||||
|
||||
this.nsec_input.update(cx, |this, cx| {
|
||||
this.set_value(keys.secret_key().to_bech32().unwrap(), window, cx);
|
||||
this.set_value(nsec.as_str(), window, cx);
|
||||
});
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(error) = result {
|
||||
log::error!("failed to load account keys: {error:#}");
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_error(format!("Failed to load your keys: {error}"), window, cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
E: Into<SharedString>,
|
||||
{
|
||||
self.error = Some(error.into());
|
||||
cx.notify();
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
// Clear the error message after a delay
|
||||
this.update(cx, |this, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
@@ -110,6 +149,15 @@ impl Render for BackupPanel {
|
||||
.child(SharedString::from(MSG)),
|
||||
)
|
||||
.child(Separator::horizontal())
|
||||
.when_some(self.error.as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
|
||||
@@ -227,10 +227,11 @@ impl BrowsePanel {
|
||||
/// Open the group's panel to preview (or revisit) it.
|
||||
fn open_panel(dock: &WeakEntity<DockArea>, key: GroupKey, window: &mut Window, cx: &mut App) {
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
let group = registry.update(cx, |registry, cx| registry.open(key, cx));
|
||||
let group = registry.update(cx, |registry, cx| registry.open(key.clone(), cx));
|
||||
|
||||
crate::dock::add_panel_to(
|
||||
crate::dock::add_group_panel_to(
|
||||
dock,
|
||||
key,
|
||||
PanelHandle::new(nip29_ui::init(group, window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
|
||||
@@ -29,26 +29,17 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
|
||||
pub struct ContactListPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Npub input
|
||||
input: Entity<InputState>,
|
||||
|
||||
/// Whether the panel is updating
|
||||
updating: bool,
|
||||
|
||||
/// Error message
|
||||
error: Option<SharedString>,
|
||||
|
||||
/// All contacts
|
||||
contacts: HashSet<PublicKey>,
|
||||
|
||||
contacts: HashSet<Contact>,
|
||||
/// Person directory for the contacts
|
||||
directory: Entity<Directory>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
/// Background tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
@@ -92,33 +83,50 @@ impl ContactListPanel {
|
||||
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()
|
||||
.author(public_key)
|
||||
.kind(Kind::ContactList)
|
||||
.limit(1);
|
||||
|
||||
let contact_list: HashSet<PublicKey> = client
|
||||
let contact_list: HashSet<Contact> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.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();
|
||||
|
||||
Ok(contact_list)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let public_keys = task.await?;
|
||||
let contacts = task.await?;
|
||||
|
||||
// Update state
|
||||
this.update(cx, |this, 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();
|
||||
})?;
|
||||
|
||||
@@ -130,7 +138,7 @@ impl ContactListPanel {
|
||||
let value = self.input.read(cx).value().to_string();
|
||||
|
||||
if let Ok(public_key) = PublicKey::parse(&value) {
|
||||
if self.contacts.insert(public_key) {
|
||||
if self.contacts.insert(Contact::new(public_key)) {
|
||||
self.directory
|
||||
.update(cx, |directory, cx| directory.track([public_key], cx));
|
||||
self.input.update(cx, |this, cx| {
|
||||
@@ -144,7 +152,8 @@ impl ContactListPanel {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -184,11 +193,7 @@ impl ContactListPanel {
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
// Get contacts
|
||||
let contacts: Vec<Contact> = self
|
||||
.contacts
|
||||
.iter()
|
||||
.map(|public_key| Contact::new(*public_key))
|
||||
.collect();
|
||||
let contacts: Vec<Contact> = self.contacts.iter().cloned().collect();
|
||||
|
||||
// Set updating state
|
||||
self.set_updating(true, cx);
|
||||
@@ -230,12 +235,14 @@ impl ContactListPanel {
|
||||
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for (ix, public_key) in self.contacts.iter().enumerate() {
|
||||
let profile = self.directory.read(cx).person(public_key, cx);
|
||||
for contact in &self.contacts {
|
||||
let public_key = contact.public_key;
|
||||
let hex = public_key.to_hex();
|
||||
let profile = self.directory.read(cx).person(&public_key, cx);
|
||||
|
||||
items.push(
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.id(hex.clone())
|
||||
.group("")
|
||||
.flex_1()
|
||||
.w_full()
|
||||
@@ -256,18 +263,15 @@ impl ContactListPanel {
|
||||
.child(profile.name()),
|
||||
)
|
||||
.child(
|
||||
Button::new("remove_{ix}")
|
||||
Button::new(format!("remove-{hex}"))
|
||||
.icon(IconName::X)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click({
|
||||
let public_key = public_key.to_owned();
|
||||
cx.listener(move |this, _ev, _window, cx| {
|
||||
this.remove(&public_key, cx);
|
||||
})
|
||||
}),
|
||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||
this.remove(&public_key, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -213,17 +213,17 @@ impl GreeterPanel {
|
||||
} else {
|
||||
requests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (room, preview))| {
|
||||
.map(|(room, preview)| {
|
||||
let room = room.clone();
|
||||
let dock = dock.clone();
|
||||
let room_id = room.read(cx).id;
|
||||
let name = room.read(cx).display_name(cx);
|
||||
let seed = room.read(cx).display_image_seed(cx);
|
||||
let created_at = room.read(cx).created_at.to_ago();
|
||||
let preview = preview.clone();
|
||||
|
||||
h_flex()
|
||||
.id(format!("request-{index}"))
|
||||
.id(format!("request-{room_id}"))
|
||||
.group("")
|
||||
.w_full()
|
||||
.h_9()
|
||||
@@ -280,7 +280,7 @@ impl GreeterPanel {
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("response-{index}"))
|
||||
Button::new(format!("response-{room_id}"))
|
||||
.label("Response")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
@@ -332,17 +332,17 @@ impl GreeterPanel {
|
||||
let rows: Vec<AnyElement> = self
|
||||
.follow_ups
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (room, preview))| {
|
||||
.map(|(room, preview)| {
|
||||
let room = room.clone();
|
||||
let dock = dock.clone();
|
||||
let room_id = room.read(cx).id;
|
||||
let name = room.read(cx).display_name(cx);
|
||||
let seed = room.read(cx).display_image_seed(cx);
|
||||
let created_at = room.read(cx).created_at.to_ago();
|
||||
let preview = preview.clone();
|
||||
|
||||
h_flex()
|
||||
.id(format!("follow-up-{index}"))
|
||||
.id(format!("follow-up-{room_id}"))
|
||||
.group("")
|
||||
.w_full()
|
||||
.h_9()
|
||||
@@ -399,7 +399,7 @@ impl GreeterPanel {
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("reply-{index}"))
|
||||
Button::new(format!("reply-{room_id}"))
|
||||
.label("Reply")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use auto_update::AutoUpdater;
|
||||
use auto_update::{AutoUpdater, UpdateDisplay};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui::AnyElement;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Anchor, App, Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString,
|
||||
Stateful, Styled, Window, div, px,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui::{AnyElement, Entity};
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui_kit::component::Disableable;
|
||||
@@ -35,7 +35,7 @@ impl Sidebar {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Stateful<Div> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let updater = AutoUpdater::try_global(cx);
|
||||
let updater_display = self.updater_display.read(cx).clone();
|
||||
let profile = self.directory.read(cx).person(current_user, cx);
|
||||
let avatar = profile.avatar();
|
||||
let encryption_key = profile
|
||||
@@ -78,8 +78,8 @@ impl Sidebar {
|
||||
let actions = h_flex().gap_1();
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let actions = actions.when_some(updater, |this, updater| {
|
||||
this.child(self.render_updater(updater, cx))
|
||||
let actions = actions.when_some(updater_display, |this, display| {
|
||||
this.child(self.render_updater(display))
|
||||
});
|
||||
|
||||
actions
|
||||
@@ -233,10 +233,12 @@ impl Sidebar {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
|
||||
let status = updater.read(cx).status();
|
||||
let up_to_date = updater.read(cx).up_to_date();
|
||||
let staged = updater.read(cx).staged();
|
||||
fn render_updater(&self, display: UpdateDisplay) -> AnyElement {
|
||||
let UpdateDisplay {
|
||||
up_to_date,
|
||||
staged,
|
||||
status,
|
||||
} = display;
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
@@ -257,10 +259,12 @@ impl Sidebar {
|
||||
.tooltip("Quit and relaunch into the installed update")
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(move |_, _window, cx| {
|
||||
updater.update(cx, |this, cx| {
|
||||
this.restart(cx);
|
||||
});
|
||||
.on_click(|_, _window, cx| {
|
||||
if let Some(updater) = AutoUpdater::try_global(cx) {
|
||||
updater.update(cx, |this, cx| {
|
||||
this.restart(cx);
|
||||
});
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -173,14 +173,20 @@ impl Sidebar {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The chat requests awaiting a response.
|
||||
/// The chat requests awaiting a response, followed by the trash entry when
|
||||
/// it holds messages that could not be decrypted.
|
||||
pub(super) fn requests_rows(&self, cx: &App) -> Vec<SidebarRow> {
|
||||
ChatRegistry::global(cx)
|
||||
.read(cx)
|
||||
let chat = ChatRegistry::global(cx).read(cx);
|
||||
let mut rows: Vec<SidebarRow> = chat
|
||||
.rooms(&RoomKind::Request, cx)
|
||||
.into_iter()
|
||||
.map(|room| SidebarRow::Room { room })
|
||||
.collect()
|
||||
.collect();
|
||||
|
||||
if chat.trashed_count(cx) > 0 {
|
||||
rows.push(SidebarRow::Trash);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// The overlay with the create button and selection count.
|
||||
|
||||
@@ -34,11 +34,44 @@ impl ListSection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap snapshot of the cached rows' inputs, compared on every render; covers
|
||||
/// inputs that never notify the sidebar (pin edits, room kind promotions, and
|
||||
/// the visibility flags mutated across the sidebar's modules).
|
||||
#[derive(Default, PartialEq)]
|
||||
pub(super) struct RowsSignature {
|
||||
contacts_open: bool,
|
||||
requests_open: bool,
|
||||
contacts_loaded: bool,
|
||||
pins_open: bool,
|
||||
groups_open: bool,
|
||||
chats_open: bool,
|
||||
pinned_rooms: Vec<u64>,
|
||||
pinned_groups: Vec<String>,
|
||||
/// Kinds of the rooms held by the cache; a request can turn ongoing with
|
||||
/// no event or registry notify the sidebar can hear
|
||||
room_kinds: Vec<(u64, RoomKind)>,
|
||||
/// Size of the undecryptable-messages trash, which no notify reaches
|
||||
trashed: usize,
|
||||
}
|
||||
|
||||
/// Read the id and kind of every room row held by the given cache.
|
||||
fn room_signature(rows: &[SidebarRow], cx: &App) -> Vec<(u64, RoomKind)> {
|
||||
rows.iter()
|
||||
.filter_map(|row| match row {
|
||||
SidebarRow::Room { room } => {
|
||||
let room = room.read(cx);
|
||||
Some((room.id, room.kind))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
pub(super) fn render_list(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let requests_open = self.requests_open;
|
||||
let contacts_open = self.contacts_open && !requests_open;
|
||||
let rows = Rc::new(self.rows_for(cx));
|
||||
let rows = self.cached_rows(cx);
|
||||
// Whether to show the create button for the contact picker
|
||||
let create_button = contacts_open && !self.selected_contacts.is_empty();
|
||||
|
||||
@@ -50,9 +83,10 @@ impl Sidebar {
|
||||
("sidebar-list", None)
|
||||
};
|
||||
|
||||
let request_count = ChatRegistry::global(cx)
|
||||
.read(cx)
|
||||
.count(&RoomKind::Request, cx);
|
||||
let request_count = {
|
||||
let chat = ChatRegistry::global(cx).read(cx);
|
||||
chat.count(&RoomKind::Request, cx) + chat.trashed_count(cx)
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
@@ -67,17 +101,23 @@ impl Sidebar {
|
||||
.gap_1()
|
||||
.items_start()
|
||||
.justify_start()
|
||||
.when(cfg!(not(target_arch = "wasm32")), |this| {
|
||||
this.child(
|
||||
Nav::new("wallet")
|
||||
.prefix(Icon::new(gpui_kit::assets::IconName::Wallet).small())
|
||||
.label("Wallet")
|
||||
.text_sm()
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(crate::Command::ShowWallet), cx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(
|
||||
cfg!(not(target_arch = "wasm32")) && AppSettings::get_wallet(cx),
|
||||
|this| {
|
||||
this.child(
|
||||
Nav::new("wallet")
|
||||
.prefix(Icon::new(gpui_kit::assets::IconName::Wallet).small())
|
||||
.label("Wallet")
|
||||
.text_sm()
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(crate::Command::ShowWallet),
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(
|
||||
Nav::new("browse")
|
||||
.prefix(Icon::new(IconName::Compass).small())
|
||||
@@ -169,6 +209,32 @@ impl Sidebar {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Rebuild the rows only when a dirty source fired or the snapshot changed.
|
||||
fn cached_rows(&mut self, cx: &mut Context<Self>) -> Rc<Vec<SidebarRow>> {
|
||||
let mut signature = RowsSignature {
|
||||
contacts_open: self.contacts_open,
|
||||
requests_open: self.requests_open,
|
||||
contacts_loaded: self.contacts.is_some(),
|
||||
pins_open: self.pins_open,
|
||||
groups_open: self.is_groups_open(cx),
|
||||
chats_open: self.is_chats_open(cx),
|
||||
pinned_rooms: AppSettings::get_pinned_rooms(cx),
|
||||
pinned_groups: AppSettings::get_pinned_groups(cx),
|
||||
room_kinds: room_signature(&self.rows, cx),
|
||||
trashed: ChatRegistry::global(cx).read(cx).trashed_count(cx),
|
||||
};
|
||||
|
||||
if self.rows_dirty || self.rows_signature != signature {
|
||||
self.rows = Rc::new(self.rows_for(cx));
|
||||
self.rows_dirty = false;
|
||||
// Describe the rebuilt cache, so the next render compares apples to apples
|
||||
signature.room_kinds = room_signature(&self.rows, cx);
|
||||
}
|
||||
|
||||
self.rows_signature = signature;
|
||||
Rc::clone(&self.rows)
|
||||
}
|
||||
|
||||
fn rows_for(&self, cx: &App) -> Vec<SidebarRow> {
|
||||
if self.contacts_open {
|
||||
return self.contacts_rows();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::Error;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use auto_update::{AutoUpdater, UpdateDisplay};
|
||||
use chat::{ChatEvent, ChatRegistry};
|
||||
use common::DebouncedDelay;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
@@ -16,12 +19,13 @@ use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::progress::ProgressCircle;
|
||||
use gpui_kit::component::{ActiveTheme, WindowExt, h_flex, v_flex};
|
||||
use instant::Duration;
|
||||
use list::RowsSignature;
|
||||
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Directory, PersonRegistry};
|
||||
use rows::SidebarRow;
|
||||
pub(crate) use rows::open_room;
|
||||
use search::SearchResult;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
||||
use ui::title_bar_drag_handlers;
|
||||
@@ -44,7 +48,6 @@ pub struct Sidebar {
|
||||
dock: WeakEntity<DockArea>,
|
||||
/// Random background shown behind the signed-out screen.
|
||||
banner: SharedString,
|
||||
|
||||
/// The group whose information the sidebar is browsing, if any.
|
||||
group: Option<WeakEntity<Group>>,
|
||||
/// Whether the sidebar is showing the group's information instead of its list.
|
||||
@@ -53,14 +56,12 @@ pub struct Sidebar {
|
||||
admins_open: bool,
|
||||
/// Whether the group view's members section is expanded.
|
||||
members_open: bool,
|
||||
|
||||
/// Whether the main list's pinned section is expanded.
|
||||
pins_open: bool,
|
||||
/// Whether the user expanded or collapsed the main list's groups section.
|
||||
groups_open: Option<bool>,
|
||||
/// Same as `groups_open` for the chats section.
|
||||
chats_open: Option<bool>,
|
||||
|
||||
/// Whether the main list is showing contacts to start a chat with.
|
||||
contacts_open: bool,
|
||||
/// The signed-in user's contacts, loaded when the contact picker opens.
|
||||
@@ -69,11 +70,9 @@ pub struct Sidebar {
|
||||
directory: Entity<Directory>,
|
||||
/// Contacts selected in the contact picker.
|
||||
selected_contacts: HashSet<PublicKey>,
|
||||
|
||||
/// Whether the main list is showing chat requests instead of chats and groups.
|
||||
requests_open: bool,
|
||||
new_requests: bool,
|
||||
|
||||
/// Whether the search bar is shown below the header.
|
||||
search_open: bool,
|
||||
/// Whether searches also cover global (relay) profiles.
|
||||
@@ -90,12 +89,19 @@ pub struct Sidebar {
|
||||
global_results: Option<Vec<PublicKey>>,
|
||||
/// The async global profile search.
|
||||
global_task: Option<Task<()>>,
|
||||
|
||||
/// Simulated gift wrap processing progress, shown while the backlog loads
|
||||
chat_progress: f32,
|
||||
/// Simulated web of trust sync progress
|
||||
sync_progress: f32,
|
||||
|
||||
/// Cached main list rows; rebuilt only when invalidation is signaled
|
||||
rows: Rc<Vec<SidebarRow>>,
|
||||
/// Snapshot of the cached rows' inputs, compared on every render
|
||||
rows_signature: RowsSignature,
|
||||
/// Whether the rows changed through a source the snapshot cannot see
|
||||
rows_dirty: bool,
|
||||
/// The updater facts the header renders, mirrored so updater churn cannot invalidate the cached sidebar
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updater_display: Entity<Option<UpdateDisplay>>,
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||
}
|
||||
@@ -105,7 +111,6 @@ impl Sidebar {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let groups = GroupsRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let settings = AppSettings::global(cx);
|
||||
let directory = Directory::new(cx);
|
||||
|
||||
let find_input = cx.new(|cx| {
|
||||
@@ -122,10 +127,12 @@ impl Sidebar {
|
||||
match event {
|
||||
ChatEvent::Ping => {
|
||||
this.new_requests = true;
|
||||
this.rows_dirty = true;
|
||||
cx.notify();
|
||||
}
|
||||
ChatEvent::Loaded => {
|
||||
this.chat_progress = 0.0;
|
||||
this.rows_dirty = true;
|
||||
cx.notify();
|
||||
}
|
||||
_ => {}
|
||||
@@ -134,9 +141,10 @@ impl Sidebar {
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
|
||||
cx.subscribe_in(&groups, window, |this, _, event, window, cx| {
|
||||
match event {
|
||||
GroupsEvent::Updated => {
|
||||
this.rows_dirty = true;
|
||||
// TODO: we must not trigger re-render directly like this
|
||||
cx.notify();
|
||||
}
|
||||
@@ -149,11 +157,24 @@ impl Sidebar {
|
||||
} => {
|
||||
window.push_notification(Notification::warning(warning.clone()), cx);
|
||||
}
|
||||
// New groups can arrive without `Updated` and without a
|
||||
// registry notify, so only the event itself can dirty the cache
|
||||
GroupsEvent::Synced | GroupsEvent::Created { .. } => {
|
||||
this.rows_dirty = true;
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(cx.observe(&chat, |this, _, _| {
|
||||
this.rows_dirty = true;
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.observe(&groups, |this, _, _| {
|
||||
this.rows_dirty = true;
|
||||
}));
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(
|
||||
&nostr,
|
||||
@@ -209,10 +230,23 @@ impl Sidebar {
|
||||
},
|
||||
));
|
||||
|
||||
// The user header reads settings and the tracked profiles on render, so
|
||||
// the encryption button reacts to toggles and announcement arrivals
|
||||
subscriptions.push(cx.observe(&settings, |_, _, cx| cx.notify()));
|
||||
subscriptions.push(cx.observe(&directory, |_, _, cx| cx.notify()));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let updater_display =
|
||||
cx.new(|cx| AutoUpdater::try_global(cx).map(|updater| updater.read(cx).display()));
|
||||
|
||||
// Mirror only the header's rendered facts; updater churn must not invalidate the whole cached sidebar
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(updater) = AutoUpdater::try_global(cx) {
|
||||
subscriptions.push(cx.observe(&updater, |this, updater, cx| {
|
||||
let display = updater.read(cx).display();
|
||||
this.updater_display.update(cx, |state, cx| {
|
||||
if state.as_ref() != Some(&display) {
|
||||
*state = Some(display);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
tasks.push(cx.spawn(async move |this, cx| {
|
||||
let timer = Duration::from_millis(PROGRESS_TICK_MS);
|
||||
@@ -251,6 +285,11 @@ impl Sidebar {
|
||||
global_task: None,
|
||||
chat_progress: 0.0,
|
||||
sync_progress: 0.0,
|
||||
rows: Rc::default(),
|
||||
rows_signature: RowsSignature::default(),
|
||||
rows_dirty: true,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updater_display,
|
||||
tasks,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use chat::{Room, RoomKind};
|
||||
use chat::{ChatRegistry, Room, RoomKind};
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
@@ -9,7 +9,7 @@ use gpui::{
|
||||
};
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
|
||||
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
|
||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex};
|
||||
use nip29::Group;
|
||||
@@ -30,9 +30,12 @@ pub(crate) fn open_room(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let panel = chat_ui::init(room.downgrade(), window, cx);
|
||||
let room_id = panel.read(cx).room_id().clone();
|
||||
|
||||
crate::dock::add_panel_to(
|
||||
dock,
|
||||
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
|
||||
PanelHandle::new(panel),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
@@ -41,8 +44,11 @@ pub(crate) fn open_room(
|
||||
if room.read(cx).kind != RoomKind::Ongoing && AppSettings::get_screening(cx) {
|
||||
let peer = room.read(cx).display_member(cx).public_key();
|
||||
let view = screening::init(peer, window, cx);
|
||||
let dock = dock.clone();
|
||||
|
||||
window.open_alert_dialog(cx, move |alert, _window, _cx| {
|
||||
let dock = dock.clone();
|
||||
let room_id = room_id.clone();
|
||||
alert
|
||||
.confirm()
|
||||
.child(view.clone())
|
||||
@@ -52,7 +58,11 @@ pub(crate) fn open_room(
|
||||
.ok_text("Response"),
|
||||
)
|
||||
.on_cancel(move |_event, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
if let Err(error) = dock.update(cx, |area, cx| {
|
||||
crate::dock::close_room_panel(area, &room_id, window, cx);
|
||||
}) {
|
||||
log::warn!("Failed to close the request room's tab: {error}");
|
||||
}
|
||||
true
|
||||
})
|
||||
});
|
||||
@@ -78,6 +88,8 @@ pub(super) enum SidebarRow {
|
||||
Message {
|
||||
hit: MessageHit,
|
||||
},
|
||||
/// Entry point to the trash panel, shown under requests
|
||||
Trash,
|
||||
/// A non-interactive label, such as a search results section.
|
||||
Heading(&'static str),
|
||||
Empty(&'static str),
|
||||
@@ -95,10 +107,7 @@ impl Sidebar {
|
||||
rows.get(range.clone())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.map(|(offset, row)| {
|
||||
let index = range.start + offset;
|
||||
|
||||
.map(|row| {
|
||||
let element = match row {
|
||||
SidebarRow::Section(section) => {
|
||||
let section = *section;
|
||||
@@ -171,7 +180,7 @@ impl Sidebar {
|
||||
let dock = self.dock.clone();
|
||||
let room = room.clone();
|
||||
|
||||
let nav = Nav::new(index)
|
||||
let nav = Nav::new(id)
|
||||
.label(name)
|
||||
.text_sm()
|
||||
.when(!hide_avatar, |this| {
|
||||
@@ -210,7 +219,7 @@ impl Sidebar {
|
||||
});
|
||||
|
||||
div()
|
||||
.id(index)
|
||||
.id(id)
|
||||
.w_full()
|
||||
.child(nav)
|
||||
.context_menu(move |menu, _window, _cx| {
|
||||
@@ -241,7 +250,7 @@ impl Sidebar {
|
||||
let sidebar = cx.entity().downgrade();
|
||||
let public_key = public_key.to_owned();
|
||||
|
||||
Nav::new(index)
|
||||
Nav::new(public_key.to_hex())
|
||||
.label(person.name())
|
||||
.text_sm()
|
||||
.when(!hide_avatar, |this| {
|
||||
@@ -265,7 +274,7 @@ impl Sidebar {
|
||||
let person = self.directory.read(cx).person(public_key, cx);
|
||||
let public_key = public_key.to_owned();
|
||||
|
||||
Nav::new(index)
|
||||
Nav::new(public_key.to_hex())
|
||||
.label(person.name())
|
||||
.text_sm()
|
||||
.when(!hide_avatar, |this| {
|
||||
@@ -298,7 +307,7 @@ impl Sidebar {
|
||||
.or_else(|| group.read(cx).elsewhere().map(|_| "Moved"))
|
||||
.map(SharedString::from);
|
||||
|
||||
let nav = Nav::new(index)
|
||||
let nav = Nav::new(tag.clone())
|
||||
.label(name)
|
||||
.text_sm()
|
||||
.when(!hide_avatar, |this| {
|
||||
@@ -318,8 +327,11 @@ impl Sidebar {
|
||||
)
|
||||
})
|
||||
.on_click(move |_event, window, cx| {
|
||||
crate::dock::add_panel_to(
|
||||
let group_key = group.read(cx).key().clone();
|
||||
|
||||
crate::dock::add_group_panel_to(
|
||||
&dock,
|
||||
group_key,
|
||||
PanelHandle::new(nip29_ui::init(group.clone(), window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
@@ -334,7 +346,7 @@ impl Sidebar {
|
||||
});
|
||||
|
||||
div()
|
||||
.id(index)
|
||||
.id(tag.clone())
|
||||
.w_full()
|
||||
.child(nav)
|
||||
.context_menu(move |menu, _window, _cx| {
|
||||
@@ -370,8 +382,15 @@ impl Sidebar {
|
||||
let dock = self.dock.clone();
|
||||
let room = hit.room.clone();
|
||||
let content = hit.content.clone();
|
||||
// Several hits can share a room, so the id digests the whole hit
|
||||
let hit_id = common::stable_id_u64(&[
|
||||
hit.room.read(cx).id.to_string().as_str(),
|
||||
hit.author.to_string().as_str(),
|
||||
hit.created_at.as_secs().to_string().as_str(),
|
||||
hit.content.as_ref(),
|
||||
]);
|
||||
|
||||
Nav::new(index)
|
||||
Nav::new(hit_id)
|
||||
.label(content)
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
@@ -394,6 +413,34 @@ impl Sidebar {
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
SidebarRow::Trash => {
|
||||
let count = ChatRegistry::global(cx).read(cx).trashed_count(cx);
|
||||
let dock = self.dock.clone();
|
||||
|
||||
Nav::new("sidebar-trash")
|
||||
.label("Trash")
|
||||
.text_sm()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(Avatar::new(None).seed("trash").small())
|
||||
})
|
||||
.suffix(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(count.to_string())),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
let panel = chat_ui::trash::init(window, cx);
|
||||
crate::dock::add_panel_to(
|
||||
&dock,
|
||||
PanelHandle::new(panel),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
// Keep a small gap between rows so selected rows read as pills.
|
||||
div().pb_0p5().child(element).into_any_element()
|
||||
|
||||
+1
-5
@@ -14,7 +14,7 @@ product-name = "Coop"
|
||||
description = "Chat Freely, Stay Private on Nostr"
|
||||
identifier = "su.reya.coop"
|
||||
category = "SocialNetworking"
|
||||
version = "2.0.0-alpha3"
|
||||
version = "2026.11.1"
|
||||
out-dir = "../dist"
|
||||
before-packaging-command = "cargo build --release"
|
||||
resources = ["Cargo.toml", "src"]
|
||||
@@ -34,7 +34,6 @@ info_plist_path = "resources/Info.plist"
|
||||
[dependencies]
|
||||
assets = { path = "../crates/assets" }
|
||||
workspace = { path = "../crates/workspace" }
|
||||
ui = { path = "../crates/ui" }
|
||||
state = { path = "../crates/state" }
|
||||
nip4e = { path = "../crates/nip4e" }
|
||||
nip60 = { path = "../crates/nip60" }
|
||||
@@ -48,9 +47,6 @@ person = { path = "../crates/person" }
|
||||
gpui.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
gpui-kit.workspace = true
|
||||
gpui_linux.workspace = true
|
||||
gpui_windows.workspace = true
|
||||
gpui_macos.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
|
||||
log.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ crate-type = ["cdylib", "rlib"]
|
||||
[dependencies]
|
||||
assets = { path = "../crates/assets" }
|
||||
workspace = { path = "../crates/workspace" }
|
||||
ui = { path = "../crates/ui" }
|
||||
state = { path = "../crates/state" }
|
||||
nip4e = { path = "../crates/nip4e" }
|
||||
chat = { path = "../crates/chat" }
|
||||
|
||||
Reference in New Issue
Block a user