stabilize ids and harden release pipeline
This commit is contained in:
Generated
+2
@@ -1463,6 +1463,7 @@ dependencies = [
|
|||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"instant",
|
"instant",
|
||||||
"itertools 0.13.0",
|
"itertools 0.13.0",
|
||||||
|
"log",
|
||||||
"nip60_ui",
|
"nip60_ui",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"person",
|
"person",
|
||||||
@@ -1681,6 +1682,7 @@ dependencies = [
|
|||||||
"nostr",
|
"nostr",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"regex",
|
"regex",
|
||||||
|
"sha2 0.10.9",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,18 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
|||||||
|
|
||||||
impl Global for GlobalAutoUpdater {}
|
impl Global for GlobalAutoUpdater {}
|
||||||
|
|
||||||
|
/// The subset of updater state the UI renders, so views can hold it in a small
|
||||||
|
/// entity instead of observing the whole updater.
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct UpdateDisplay {
|
||||||
|
/// Whether the running version is the newest release.
|
||||||
|
pub up_to_date: bool,
|
||||||
|
/// Whether a verified update is installed and waiting for a restart.
|
||||||
|
pub staged: bool,
|
||||||
|
/// A short, human-readable description of the current status.
|
||||||
|
pub status: SharedString,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AutoUpdater {
|
pub struct AutoUpdater {
|
||||||
updater: Entity<Updater>,
|
updater: Entity<Updater>,
|
||||||
status: UpdateStatus,
|
status: UpdateStatus,
|
||||||
@@ -173,6 +185,15 @@ impl AutoUpdater {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Snapshot the state the UI renders.
|
||||||
|
pub fn display(&self) -> UpdateDisplay {
|
||||||
|
UpdateDisplay {
|
||||||
|
up_to_date: self.up_to_date(),
|
||||||
|
staged: self.staged(),
|
||||||
|
status: self.status(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check the release host for a newer version, then download and install it.
|
/// Check the release host for a newer version, then download and install it.
|
||||||
pub fn check(&mut self, cx: &mut Context<Self>) {
|
pub fn check(&mut self, cx: &mut Context<Self>) {
|
||||||
self.updater.update(cx, |updater, cx| {
|
self.updater.update(cx, |updater, cx| {
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ pub use self::error::Error;
|
|||||||
const DEFAULT_HTML: &str = include_str!("../index.html");
|
const DEFAULT_HTML: &str = include_str!("../index.html");
|
||||||
const JS: &str = include_str!("../proxy.js");
|
const JS: &str = include_str!("../proxy.js");
|
||||||
|
|
||||||
|
/// Cap on the request line and headers so a peer cannot force unbounded reads
|
||||||
|
const MAX_HEADER_BYTES: usize = 16 * 1024;
|
||||||
|
/// Cap on the request body so a huge Content-Length cannot force a huge allocation
|
||||||
|
const MAX_BODY_BYTES: usize = 1024 * 1024;
|
||||||
|
/// Maximum time a connection may stay open before it is dropped
|
||||||
|
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
|
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -301,6 +308,8 @@ impl BrowserSignerProxy {
|
|||||||
smol::spawn(async move {
|
smol::spawn(async move {
|
||||||
let shutdown_listener = shutdown.listen();
|
let shutdown_listener = shutdown.listen();
|
||||||
|
|
||||||
|
smol::future::or(
|
||||||
|
async {
|
||||||
smol::future::or(
|
smol::future::or(
|
||||||
async {
|
async {
|
||||||
handle_connection(
|
handle_connection(
|
||||||
@@ -312,6 +321,12 @@ impl BrowserSignerProxy {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
},
|
},
|
||||||
|
async {
|
||||||
|
smol::Timer::after(CONNECTION_TIMEOUT).await;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
},
|
||||||
async {
|
async {
|
||||||
shutdown_listener.await;
|
shutdown_listener.await;
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -401,10 +416,19 @@ impl BrowserSignerProxy {
|
|||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
|
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
|
||||||
|
let expected_id: EventId = event.compute_id();
|
||||||
let event: Event = self
|
let event: Event = self
|
||||||
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
|
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
|
||||||
.await?;
|
.await?;
|
||||||
event.verify()?;
|
event.verify()?;
|
||||||
|
|
||||||
|
if event.id != expected_id {
|
||||||
|
return Err(Error::generic(format!(
|
||||||
|
"signed event doesn't match the requested event: expected ID {expected_id}, got {}",
|
||||||
|
event.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(event)
|
Ok(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,6 +490,54 @@ impl AsyncNip44 for BrowserSignerProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Outcome of reading a line with [`read_line_capped`].
|
||||||
|
enum HeaderLine {
|
||||||
|
/// A line, including the trailing newline when present
|
||||||
|
Line(Vec<u8>),
|
||||||
|
/// Clean EOF with no data
|
||||||
|
Eof,
|
||||||
|
/// The line exceeded the allowed length
|
||||||
|
TooLong,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one `\n`-terminated line with a hard cap so a peer cannot force unbounded buffering.
|
||||||
|
async fn read_line_capped(
|
||||||
|
reader: &mut BufReader<TcpStream>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<HeaderLine, std::io::Error> {
|
||||||
|
let mut line: Vec<u8> = Vec::new();
|
||||||
|
loop {
|
||||||
|
let (data, line_complete) = {
|
||||||
|
let chunk = reader.fill_buf().await?;
|
||||||
|
if chunk.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match chunk.iter().position(|byte| *byte == b'\n') {
|
||||||
|
Some(index) => (chunk[..=index].to_vec(), true),
|
||||||
|
None => (chunk.to_vec(), false),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if line.len() + data.len() > limit {
|
||||||
|
return Ok(HeaderLine::TooLong);
|
||||||
|
}
|
||||||
|
|
||||||
|
let taken = data.len();
|
||||||
|
line.extend_from_slice(&data);
|
||||||
|
reader.consume(taken);
|
||||||
|
|
||||||
|
if line_complete {
|
||||||
|
return Ok(HeaderLine::Line(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(if line.is_empty() {
|
||||||
|
HeaderLine::Eof
|
||||||
|
} else {
|
||||||
|
HeaderLine::Line(line)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle a single HTTP connection.
|
/// Handle a single HTTP connection.
|
||||||
async fn handle_connection(
|
async fn handle_connection(
|
||||||
stream: TcpStream,
|
stream: TcpStream,
|
||||||
@@ -475,13 +547,26 @@ async fn handle_connection(
|
|||||||
session_token: String,
|
session_token: String,
|
||||||
) {
|
) {
|
||||||
let mut reader = BufReader::new(stream);
|
let mut reader = BufReader::new(stream);
|
||||||
|
let mut header_bytes: usize = 0;
|
||||||
|
|
||||||
// Read the request line
|
// Read the request line
|
||||||
let mut request_line = String::new();
|
let request_line = match read_line_capped(&mut reader, MAX_HEADER_BYTES).await {
|
||||||
if reader.read_line(&mut request_line).await.is_err() {
|
Ok(HeaderLine::Line(line)) => line,
|
||||||
|
Ok(HeaderLine::Eof) => Vec::new(),
|
||||||
|
Ok(HeaderLine::TooLong) => {
|
||||||
|
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let request_line = request_line.trim_end().to_string();
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
header_bytes += request_line.len();
|
||||||
|
let request_line = match String::from_utf8(request_line) {
|
||||||
|
Ok(line) => line.trim_end().to_string(),
|
||||||
|
Err(_) => {
|
||||||
|
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Parse method, path, and HTTP version from request line
|
// Parse method, path, and HTTP version from request line
|
||||||
let parts: Vec<&str> = request_line.split_whitespace().collect();
|
let parts: Vec<&str> = request_line.split_whitespace().collect();
|
||||||
@@ -498,12 +583,29 @@ async fn handle_connection(
|
|||||||
let mut host: Option<String> = None;
|
let mut host: Option<String> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let mut line = String::new();
|
let line = match read_line_capped(&mut reader, MAX_HEADER_BYTES).await {
|
||||||
if reader.read_line(&mut line).await.is_err() {
|
Ok(HeaderLine::Line(line)) => line,
|
||||||
|
Ok(HeaderLine::Eof) => break,
|
||||||
|
Ok(HeaderLine::TooLong) => {
|
||||||
|
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
header_bytes += line.len();
|
||||||
|
if header_bytes > MAX_HEADER_BYTES {
|
||||||
|
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let line = line.trim_end().to_string();
|
let line = match String::from_utf8(line) {
|
||||||
|
Ok(line) => line.trim_end().to_string(),
|
||||||
|
Err(_) => {
|
||||||
|
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -521,6 +623,12 @@ async fn handle_connection(
|
|||||||
headers.push(line);
|
headers.push(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject oversized bodies before allocating the read buffer
|
||||||
|
if content_length > MAX_BODY_BYTES {
|
||||||
|
send_response(&mut reader, 413, "Payload Too Large", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// DNS rebinding defense: only the exact bound host:port is accepted
|
// DNS rebinding defense: only the exact bound host:port is accepted
|
||||||
if host.as_deref() != Some(expected_host.as_str()) {
|
if host.as_deref() != Some(expected_host.as_str()) {
|
||||||
send_response(&mut reader, 403, "Forbidden", "", "").await;
|
send_response(&mut reader, 403, "Forbidden", "", "").await;
|
||||||
@@ -594,7 +702,17 @@ async fn handle_connection(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::debug!("Received response from browser: {message:?}");
|
// Never log the response value, it can contain decrypted plaintext
|
||||||
|
let outcome = if message.error.is_some() {
|
||||||
|
"error"
|
||||||
|
} else {
|
||||||
|
"ok"
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Received response from browser: id={} outcome={outcome}",
|
||||||
|
message.id
|
||||||
|
);
|
||||||
|
|
||||||
let id: Uuid = message.id;
|
let id: Uuid = message.id;
|
||||||
let mut pending = state.pending_responses.lock().await;
|
let mut pending = state.pending_responses.lock().await;
|
||||||
|
|||||||
+18
-1
@@ -396,10 +396,27 @@ impl ChatRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !errors.is_empty() {
|
if !errors.is_empty() {
|
||||||
|
let mut newly_trashed = 0;
|
||||||
trash.update(cx, |trash, cx| {
|
trash.update(cx, |trash, cx| {
|
||||||
trash.extend(errors);
|
newly_trashed = errors
|
||||||
|
.iter()
|
||||||
|
.filter(|message| trash.insert((*message).clone()))
|
||||||
|
.count();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Only newly trashed messages emit an error event so repeated
|
||||||
|
// reprocess attempts of the same message don't re-alert the user
|
||||||
|
if newly_trashed > 0 {
|
||||||
|
let reason = if newly_trashed == 1 {
|
||||||
|
"A message could not be decrypted".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{newly_trashed} messages could not be decrypted")
|
||||||
|
};
|
||||||
|
this.update(cx, |_, cx| {
|
||||||
|
cx.emit(ChatEvent::Error(reason));
|
||||||
|
})?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if eose && tracking.swap(false, Ordering::AcqRel) {
|
if eose && tracking.swap(false, Ordering::AcqRel) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ gpui.workspace = true
|
|||||||
instant.workspace = true
|
instant.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
log.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|||||||
+95
-14
@@ -1,3 +1,4 @@
|
|||||||
|
use std::cmp::Ordering;
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::sync::{Arc, LazyLock, RwLock};
|
use std::sync::{Arc, LazyLock, RwLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -220,7 +221,7 @@ impl ChatPanel {
|
|||||||
|
|
||||||
// Define all functions that will run after the current cycle
|
// Define all functions that will run after the current cycle
|
||||||
cx.defer_in(window, |this, window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
this.connect(cx);
|
this.connect(window, cx);
|
||||||
this.subscribe_room_events(window, cx);
|
this.subscribe_room_events(window, cx);
|
||||||
this.get_messages(window, cx);
|
this.get_messages(window, cx);
|
||||||
});
|
});
|
||||||
@@ -269,11 +270,24 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get messaging relays and announcement for each member
|
/// Get messaging relays and announcement for each member
|
||||||
fn connect(&mut self, cx: &mut Context<Self>) {
|
fn connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if let Some(room) = self.room.upgrade() {
|
let Some(room) = self.room.upgrade() else {
|
||||||
let task = room.read(cx).connect(cx);
|
return;
|
||||||
self.tasks.push(task);
|
};
|
||||||
|
|
||||||
|
let connect = room.read(cx).connect(cx);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
if let Err(error) = connect.await {
|
||||||
|
log::error!("Connecting room relays failed: {error}");
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.notify_failure("Connection failed", error, window, cx);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to room events
|
/// Subscribe to room events
|
||||||
@@ -317,23 +331,40 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load all messages belonging to this room
|
/// Load all messages belonging to this room
|
||||||
fn get_messages(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
fn get_messages(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else {
|
let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
let events = get_messages.await?;
|
match get_messages.await {
|
||||||
|
Ok(events) => {
|
||||||
// Update message list
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.insert_messages(&events, cx);
|
this.insert_messages(&events, cx);
|
||||||
})?;
|
})?;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("Loading room messages failed: {error}");
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.notify_failure("Failed to load messages", error, window, cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show a failure of a background operation to the user until dismissed
|
||||||
|
fn notify_failure(&self, title: &'static str, error: Error, window: &mut Window, cx: &mut App) {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(error.to_string())
|
||||||
|
.title(title)
|
||||||
|
.autohide(false),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get user input content and merged all attachments if available
|
/// Get user input content and merged all attachments if available
|
||||||
fn get_input_value(&self, cx: &Context<Self>) -> String {
|
fn get_input_value(&self, cx: &Context<Self>) -> String {
|
||||||
// Get input's value
|
// Get input's value
|
||||||
@@ -566,8 +597,9 @@ impl ChatPanel {
|
|||||||
for (i, message) in self.messages.iter().enumerate().skip(pos) {
|
for (i, message) in self.messages.iter().enumerate().skip(pos) {
|
||||||
self.message_index.insert(message.id, i);
|
self.message_index.insert(message.id, i);
|
||||||
}
|
}
|
||||||
let len = self.list_state.item_count();
|
// The welcome row occupies row zero, so message rows sit one ahead
|
||||||
self.list_state.splice(len..len, 1);
|
let row = pos + 1;
|
||||||
|
self.list_state.splice(row..row, 1);
|
||||||
|
|
||||||
if scroll {
|
if scroll {
|
||||||
self.list_state.scroll_to(ListOffset {
|
self.list_state.scroll_to(ListOffset {
|
||||||
@@ -582,13 +614,62 @@ impl ChatPanel {
|
|||||||
|
|
||||||
/// Convert and insert a vector of nostr events into the chat panel
|
/// Convert and insert a vector of nostr events into the chat panel
|
||||||
fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context<Self>) {
|
fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context<Self>) {
|
||||||
|
let mut incoming: Vec<Message> = Vec::with_capacity(events.len());
|
||||||
|
|
||||||
for event in events.iter() {
|
for event in events.iter() {
|
||||||
if event.kind == Kind::Reaction {
|
if event.kind == Kind::Reaction {
|
||||||
self.insert_reaction(event, cx);
|
self.insert_reaction(event, cx);
|
||||||
|
} else {
|
||||||
|
incoming.push(event.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
incoming.sort();
|
||||||
|
|
||||||
|
let old_len = self.messages.len();
|
||||||
|
let mut stored = std::mem::take(&mut self.messages).into_iter().peekable();
|
||||||
|
let mut merged: Vec<Message> = Vec::with_capacity(old_len + incoming.len());
|
||||||
|
let mut insert_start = None;
|
||||||
|
let mut inserted = 0usize;
|
||||||
|
|
||||||
|
for message in incoming {
|
||||||
|
while let Some(old) = stored.next_if(|old| old.cmp(&message) == Ordering::Less) {
|
||||||
|
merged.push(old);
|
||||||
|
}
|
||||||
|
|
||||||
|
let duplicate = stored
|
||||||
|
.peek()
|
||||||
|
.is_some_and(|old| old.cmp(&message) == Ordering::Equal)
|
||||||
|
|| merged
|
||||||
|
.last()
|
||||||
|
.is_some_and(|last| last.cmp(&message) == Ordering::Equal);
|
||||||
|
|
||||||
|
if duplicate {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Bulk inserting messages, so no need to scroll to the latest message
|
|
||||||
self.insert_message(event, false, cx);
|
if inserted == 0 {
|
||||||
|
insert_start = Some(merged.len());
|
||||||
|
}
|
||||||
|
inserted += 1;
|
||||||
|
merged.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.extend(stored);
|
||||||
|
|
||||||
|
self.messages = merged;
|
||||||
|
|
||||||
|
if let Some(start) = insert_start {
|
||||||
|
self.message_index = self
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, message)| (message.id, index))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let row = start + 1;
|
||||||
|
self.list_state.splice(row..row, inserted);
|
||||||
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ gpui.workspace = true
|
|||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
instant.workspace = true
|
instant.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
|
|||||||
+99
-23
@@ -1,20 +1,18 @@
|
|||||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
|
||||||
|
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
const HEX_DIGITS: [u8; 16] = *b"0123456789abcdef";
|
||||||
|
|
||||||
pub trait EventExt {
|
pub trait EventExt {
|
||||||
|
/// Id over the event's participants, stable across Rust versions.
|
||||||
fn uniq_id(&self) -> u64;
|
fn uniq_id(&self) -> u64;
|
||||||
fn extract_public_keys(&self) -> Vec<PublicKey>;
|
fn extract_public_keys(&self) -> Vec<PublicKey>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventExt for Event {
|
impl EventExt for Event {
|
||||||
fn uniq_id(&self) -> u64 {
|
fn uniq_id(&self) -> u64 {
|
||||||
let mut hasher = DefaultHasher::new();
|
pubkey_set_uniq_id(&self.extract_public_keys())
|
||||||
let mut pubkeys: Vec<PublicKey> = self.extract_public_keys();
|
|
||||||
pubkeys.sort();
|
|
||||||
pubkeys.hash(&mut hasher);
|
|
||||||
hasher.finish()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
||||||
@@ -27,31 +25,62 @@ impl EventExt for Event {
|
|||||||
|
|
||||||
impl EventExt for UnsignedEvent {
|
impl EventExt for UnsignedEvent {
|
||||||
fn uniq_id(&self) -> u64 {
|
fn uniq_id(&self) -> u64 {
|
||||||
let mut hasher = DefaultHasher::new();
|
pubkey_set_uniq_id(&self.extract_public_keys())
|
||||||
let mut pubkeys: Vec<PublicKey> = vec![];
|
|
||||||
|
|
||||||
// Add all public keys from event
|
|
||||||
pubkeys.push(self.pubkey);
|
|
||||||
pubkeys.extend(self.tags.public_keys().collect::<Vec<_>>());
|
|
||||||
|
|
||||||
// Generate unique hash
|
|
||||||
pubkeys
|
|
||||||
.into_iter()
|
|
||||||
.unique()
|
|
||||||
.sorted()
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.hash(&mut hasher);
|
|
||||||
|
|
||||||
hasher.finish()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
||||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
|
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
|
||||||
public_keys.push(self.pubkey);
|
public_keys.push(self.pubkey);
|
||||||
|
|
||||||
public_keys.into_iter().unique().sorted().collect()
|
public_keys.into_iter().unique().sorted().collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Digest over NUL-separated parts, so part boundaries stay unambiguous.
|
||||||
|
fn parts_digest(parts: &[&str]) -> [u8; 32] {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
|
||||||
|
for part in parts {
|
||||||
|
hasher.update(part.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.finalize().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable id over parts: hex-encoded SHA-256 truncated to 128 bits.
|
||||||
|
pub fn stable_id(parts: &[&str]) -> String {
|
||||||
|
let digest = parts_digest(parts);
|
||||||
|
|
||||||
|
let mut id = String::with_capacity(32);
|
||||||
|
for byte in &digest[..16] {
|
||||||
|
id.push(HEX_DIGITS[usize::from(byte >> 4)] as char);
|
||||||
|
id.push(HEX_DIGITS[usize::from(byte & 0x0f)] as char);
|
||||||
|
}
|
||||||
|
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable 64-bit id over parts, for callers that key ids on `u64`.
|
||||||
|
pub fn stable_id_u64(parts: &[&str]) -> u64 {
|
||||||
|
let digest = parts_digest(parts);
|
||||||
|
|
||||||
|
let mut lead = [0u8; 8];
|
||||||
|
for (slot, byte) in lead.iter_mut().zip(digest.iter()) {
|
||||||
|
*slot = *byte;
|
||||||
|
}
|
||||||
|
|
||||||
|
u64::from_be_bytes(lead)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hashes the sorted hex forms of the participant set.
|
||||||
|
fn pubkey_set_uniq_id(public_keys: &[PublicKey]) -> u64 {
|
||||||
|
let mut parts: Vec<String> = public_keys.iter().map(|key| key.to_string()).collect();
|
||||||
|
parts.sort();
|
||||||
|
let parts: Vec<&str> = parts.iter().map(String::as_str).collect();
|
||||||
|
stable_id_u64(&parts)
|
||||||
|
}
|
||||||
|
|
||||||
/// Custom emoji (NIP-30) declared by `emoji` tags, first per shortcode wins
|
/// Custom emoji (NIP-30) declared by `emoji` tags, first per shortcode wins
|
||||||
pub fn emojis_from_tags(tags: &Tags) -> Vec<(String, Url)> {
|
pub fn emojis_from_tags(tags: &Tags) -> Vec<(String, Url)> {
|
||||||
let mut emojis: Vec<(String, Url)> = Vec::new();
|
let mut emojis: Vec<(String, Url)> = Vec::new();
|
||||||
@@ -100,4 +129,51 @@ mod tests {
|
|||||||
Url::parse("https://example.com/soapbox.png").unwrap()
|
Url::parse("https://example.com/soapbox.png").unwrap()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn event_with_participants(public_key: PublicKey, participants: Vec<PublicKey>) -> Event {
|
||||||
|
let tags = Tags::from_list(participants.into_iter().map(Tag::public_key).collect());
|
||||||
|
|
||||||
|
Event::new(
|
||||||
|
EventId::from_byte_array([0; 32]),
|
||||||
|
public_key,
|
||||||
|
Timestamp::from(0u64),
|
||||||
|
Kind::TextNote,
|
||||||
|
tags,
|
||||||
|
"content".to_string(),
|
||||||
|
Signature::from_byte_array([0; 64]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uniq_id_ignores_participant_order() {
|
||||||
|
let me = Keys::new(SecretKey::generate()).public_key();
|
||||||
|
let alice = Keys::new(SecretKey::generate()).public_key();
|
||||||
|
let bob = Keys::new(SecretKey::generate()).public_key();
|
||||||
|
|
||||||
|
let first = event_with_participants(me, vec![alice, bob]);
|
||||||
|
let second = event_with_participants(me, vec![bob, alice]);
|
||||||
|
|
||||||
|
assert_eq!(first.uniq_id(), second.uniq_id());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uniq_id_tracks_participants() {
|
||||||
|
let me = Keys::new(SecretKey::generate()).public_key();
|
||||||
|
let alice = Keys::new(SecretKey::generate()).public_key();
|
||||||
|
let bob = Keys::new(SecretKey::generate()).public_key();
|
||||||
|
|
||||||
|
let with_alice = event_with_participants(me, vec![alice]);
|
||||||
|
let with_bob = event_with_participants(me, vec![bob]);
|
||||||
|
|
||||||
|
assert_ne!(with_alice.uniq_id(), with_bob.uniq_id());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stable_id_is_deterministic_128_bit_hex() {
|
||||||
|
let id = stable_id(&["alpha", "beta"]);
|
||||||
|
|
||||||
|
assert_eq!(id.len(), 32);
|
||||||
|
assert!(id.chars().all(|character| character.is_ascii_hexdigit()));
|
||||||
|
assert_eq!(id, stable_id(&["alpha", "beta"]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-42
@@ -184,20 +184,11 @@ impl Iterator for FindMatches<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HandleMatch {
|
|
||||||
Token(Token),
|
|
||||||
Recursion,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct NostrParserIter<'a> {
|
pub struct NostrParserIter<'a> {
|
||||||
/// The original text
|
/// The original text
|
||||||
text: &'a str,
|
text: &'a str,
|
||||||
/// Matches found
|
/// Matches found
|
||||||
matches: FindMatches<'a>,
|
matches: FindMatches<'a>,
|
||||||
/// A pending match
|
|
||||||
pending_match: Option<Match>,
|
|
||||||
/// Last match end index
|
|
||||||
last_match_end: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> NostrParserIter<'a> {
|
impl<'a> NostrParserIter<'a> {
|
||||||
@@ -205,62 +196,65 @@ impl<'a> NostrParserIter<'a> {
|
|||||||
Self {
|
Self {
|
||||||
text,
|
text,
|
||||||
matches: FindMatches::new(text),
|
matches: FindMatches::new(text),
|
||||||
pending_match: None,
|
|
||||||
last_match_end: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_match(&mut self, mat: Match) -> HandleMatch {
|
fn handle_match(&self, mat: Match) -> Option<Token> {
|
||||||
// Update last match end
|
|
||||||
self.last_match_end = mat.end;
|
|
||||||
|
|
||||||
// Extract the matched string
|
// Extract the matched string
|
||||||
let data: &str = &self.text[mat.start..mat.end];
|
let data: &str = &self.text[mat.start..mat.end];
|
||||||
|
|
||||||
// Parse NIP-21 URI
|
// Parse NIP-21 URI
|
||||||
match Nip21::parse(data) {
|
match Nip21::parse(data) {
|
||||||
Ok(uri) => HandleMatch::Token(Token {
|
Ok(uri) => Some(Token {
|
||||||
value: uri,
|
value: uri,
|
||||||
range: mat.start..mat.end,
|
range: mat.start..mat.end,
|
||||||
}),
|
}),
|
||||||
// If the nostr URI parsing is invalid, skip it
|
// If the nostr URI parsing is invalid, skip it
|
||||||
Err(_) => HandleMatch::Recursion,
|
Err(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Iterator for NostrParserIter<'a> {
|
impl Iterator for NostrParserIter<'_> {
|
||||||
type Item = Token;
|
type Item = Token;
|
||||||
|
|
||||||
fn next(&mut self) -> Option<Self::Item> {
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
// Handle a pending match
|
// Iterative, not recursive: message text controls the match count
|
||||||
if let Some(pending_match) = self.pending_match.take() {
|
loop {
|
||||||
return match self.handle_match(pending_match) {
|
let mat = self.matches.next()?;
|
||||||
HandleMatch::Token(token) => Some(token),
|
|
||||||
HandleMatch::Recursion => self.next(),
|
if let Some(token) = self.handle_match(mat) {
|
||||||
};
|
return Some(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match self.matches.next() {
|
#[cfg(test)]
|
||||||
Some(mat) => {
|
mod tests {
|
||||||
// Skip any text before this match
|
use super::*;
|
||||||
if mat.start > self.last_match_end {
|
|
||||||
// Update pending match
|
|
||||||
// This will be handled at next iteration, in `handle_match` method.
|
|
||||||
self.pending_match = Some(mat);
|
|
||||||
|
|
||||||
// Skip the text before the match
|
// A long run of scanner matches that fail `Nip21::parse`: the count is
|
||||||
self.last_match_end = mat.start;
|
// attacker-controlled, so it must be consumed iteratively.
|
||||||
return self.next();
|
#[test]
|
||||||
|
fn skips_long_run_of_invalid_uris() {
|
||||||
|
let content = "nostr:1q".repeat(50_000);
|
||||||
|
|
||||||
|
let tokens: Vec<Token> = NostrParser::new().parse(&content).collect();
|
||||||
|
|
||||||
|
assert!(tokens.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle match
|
#[test]
|
||||||
match self.handle_match(mat) {
|
fn yields_valid_uri_after_invalid_run() {
|
||||||
HandleMatch::Token(token) => Some(token),
|
let public_key = Keys::new(SecretKey::generate()).public_key();
|
||||||
HandleMatch::Recursion => self.next(),
|
let npub = public_key.to_bech32().unwrap();
|
||||||
}
|
let content = format!("{}nostr:{npub}", "nostr:1q".repeat(50_000));
|
||||||
}
|
|
||||||
None => None,
|
let tokens: Vec<Token> = NostrParser::new().parse(&content).collect();
|
||||||
}
|
|
||||||
|
assert_eq!(tokens.len(), 1);
|
||||||
|
assert_eq!(tokens[0].range.start, 400_000);
|
||||||
|
assert_eq!(tokens[0].value, Nip21::Pubkey(public_key));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::collections::hash_map::DefaultHasher;
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use anyhow::{Result, anyhow, bail};
|
use anyhow::{Result, anyhow, bail};
|
||||||
|
use common::stable_id_u64;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
pub(crate) const TIMELINE_WINDOW: usize = 50;
|
pub(crate) const TIMELINE_WINDOW: usize = 50;
|
||||||
@@ -289,9 +288,7 @@ impl GroupKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn uniq_id(&self) -> u64 {
|
pub fn uniq_id(&self) -> u64 {
|
||||||
let mut hasher = DefaultHasher::new();
|
stable_id_u64(&[self.relay.as_str(), self.id.as_str()])
|
||||||
self.hash(&mut hasher);
|
|
||||||
hasher.finish()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::collections::hash_map::DefaultHasher;
|
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
|
|
||||||
|
use common::stable_id_u64;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
use crate::{GroupId, GroupKey};
|
use crate::{GroupId, GroupKey};
|
||||||
@@ -25,10 +24,10 @@ pub(crate) fn subscription_id(key: &GroupKey) -> SubscriptionId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn browse_subscription(relay: &RelayUrl) -> SubscriptionId {
|
pub(crate) fn browse_subscription(relay: &RelayUrl) -> SubscriptionId {
|
||||||
let mut hasher = DefaultHasher::new();
|
SubscriptionId::new(format!(
|
||||||
relay.hash(&mut hasher);
|
"{BROWSE_PREFIX}{:08x}",
|
||||||
|
stable_id_u64(&[relay.as_str()]) as u32
|
||||||
SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn page_subscription(key: &GroupKey) -> SubscriptionId {
|
pub(crate) fn page_subscription(key: &GroupKey) -> SubscriptionId {
|
||||||
|
|||||||
+72
-20
@@ -83,6 +83,8 @@ pub struct DeviceRegistry {
|
|||||||
resync: Arc<AtomicBool>,
|
resync: Arc<AtomicBool>,
|
||||||
/// Whether a key rotation is in flight, to coalesce overlapping requests
|
/// Whether a key rotation is in flight, to coalesce overlapping requests
|
||||||
rotating: bool,
|
rotating: bool,
|
||||||
|
/// Whether a key approval is in flight, to guard against a double click
|
||||||
|
approving: bool,
|
||||||
/// Person directory for requesters shown in the approval dialog
|
/// Person directory for requesters shown in the approval dialog
|
||||||
directory: Entity<Directory>,
|
directory: Entity<Directory>,
|
||||||
/// Relay notification listener
|
/// Relay notification listener
|
||||||
@@ -155,6 +157,7 @@ impl DeviceRegistry {
|
|||||||
announcement_existed: Arc::new(AtomicBool::new(false)),
|
announcement_existed: Arc::new(AtomicBool::new(false)),
|
||||||
resync: Arc::new(AtomicBool::new(false)),
|
resync: Arc::new(AtomicBool::new(false)),
|
||||||
rotating: false,
|
rotating: false,
|
||||||
|
approving: false,
|
||||||
directory: Directory::new(cx),
|
directory: Directory::new(cx),
|
||||||
notification_listener: None,
|
notification_listener: None,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
@@ -186,22 +189,31 @@ impl DeviceRegistry {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match event.kind {
|
let relevant = matches!(
|
||||||
Kind::Custom(10044) if current_user == Some(event.pubkey) => {
|
event.kind,
|
||||||
|
Kind::Custom(10044) | Kind::Custom(4454) | Kind::Custom(4455)
|
||||||
|
) && current_user == Some(event.pubkey);
|
||||||
|
|
||||||
|
if !relevant {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.kind == Kind::Custom(10044) {
|
||||||
announcement_existed.store(true, Ordering::Relaxed);
|
announcement_existed.store(true, Ordering::Relaxed);
|
||||||
tx.send_async(event.into_owned()).await?;
|
|
||||||
}
|
}
|
||||||
Kind::Custom(4454) if current_user == Some(event.pubkey) => {
|
|
||||||
tx.send_async(event.into_owned()).await?;
|
let event_id = event.id;
|
||||||
}
|
// Dropping keeps the listener alive when the consumer is gone;
|
||||||
Kind::Custom(4455) if current_user == Some(event.pubkey) => {
|
// missed 4e events are re-synced on the next signer change
|
||||||
tx.send_async(event.into_owned()).await?;
|
if let Err(error) = tx.send_async(event.into_owned()).await {
|
||||||
}
|
log::error!("Dropping device key event {event_id}: {error}");
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The stream only ends without being replaced when the relay source is
|
||||||
|
// gone, after which no 4e event is processed anymore
|
||||||
|
log::error!("Device key notification listener stopped");
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -399,10 +411,16 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let result: Result<Keys, Error> = async {
|
let result: Result<Keys, Error> = async {
|
||||||
// Archive the current key before replacing it
|
// Archive the current key before replacing it; a failed load or
|
||||||
if let Ok(old) = get_keys(&client, &signer).await {
|
// archive aborts the rotation so the old key is never overwritten
|
||||||
archive_key(&client, &signer, &old.secret_key().to_secret_hex()).await?;
|
// without a record
|
||||||
}
|
let old = get_keys(&client, &signer)
|
||||||
|
.await
|
||||||
|
.context("Failed to load the current encryption key before rotation")?;
|
||||||
|
|
||||||
|
archive_key(&client, &signer, &old.secret_key().to_secret_hex())
|
||||||
|
.await
|
||||||
|
.context("Failed to archive the current encryption key")?;
|
||||||
|
|
||||||
let created = this
|
let created = this
|
||||||
.update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))?
|
.update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))?
|
||||||
@@ -772,12 +790,21 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
/// Approve requests for device keys from other devices
|
/// Approve requests for device keys from other devices
|
||||||
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
|
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
// An approval already in flight must not send the device secret twice;
|
||||||
|
// the toast's disabled state only applies after its next re-render
|
||||||
|
if self.approving {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.approving = true;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
let approved_id = event.id;
|
||||||
|
let event = event.clone();
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
let event = event.clone();
|
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
// Get device keys
|
// Get device keys
|
||||||
let keys = get_keys(&client, &signer).await?;
|
let keys = get_keys(&client, &signer).await?;
|
||||||
@@ -834,17 +861,41 @@ impl DeviceRegistry {
|
|||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
this.update(cx, |this, cx| {
|
let superseded = this.update(cx, |this, cx| {
|
||||||
this.pending_request = None;
|
this.approving = false;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
// Keep a newer request's pending state and toast
|
||||||
|
if this
|
||||||
|
.pending_request
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|pending| pending.event.id == approved_id)
|
||||||
|
{
|
||||||
|
this.pending_request = None;
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
if !superseded {
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
window.remove_notification::<DeviceNotification>(cx);
|
window.remove_notification::<DeviceNotification>(cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.approving = false;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
// Pushing a fresh toast replaces the wedged one whose
|
||||||
|
// button stays disabled
|
||||||
|
if let Some(pending) = this.pending_request.take() {
|
||||||
|
this.show_approval(pending.event, pending.client_key, cx);
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
window.push_notification(
|
window.push_notification(
|
||||||
Notification::error(e.to_string()).autohide(false),
|
Notification::error(e.to_string()).autohide(false),
|
||||||
@@ -1032,9 +1083,10 @@ impl DeviceRegistry {
|
|||||||
.on_click({
|
.on_click({
|
||||||
let loading = Rc::clone(&loading);
|
let loading = Rc::clone(&loading);
|
||||||
move |_ev, window, cx| {
|
move |_ev, window, cx| {
|
||||||
// Set loading state to true
|
|
||||||
loading.set(true);
|
loading.set(true);
|
||||||
// Process to approve the request
|
// The toast caches its render, so force a redraw
|
||||||
|
// to disable the button while the approval is in flight
|
||||||
|
window.refresh();
|
||||||
view.update(cx, |this, cx| {
|
view.update(cx, |this, cx| {
|
||||||
this.approve(&event, window, cx);
|
this.approve(&event, window, cx);
|
||||||
})
|
})
|
||||||
|
|||||||
+15
-1
@@ -193,6 +193,16 @@ impl WalletRegistry {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop a pending invoice at the user's request.
|
||||||
|
pub fn dismiss_mint_quote(&mut self, quote_id: String, cx: &mut Context<Self>) {
|
||||||
|
if !self.quotes.iter().any(|quote| quote.quote_id == quote_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.quotes.retain(|quote| quote.quote_id != quote_id);
|
||||||
|
self.emit_updated(cx);
|
||||||
|
}
|
||||||
|
|
||||||
/// Pay a lightning invoice from the wallet.
|
/// Pay a lightning invoice from the wallet.
|
||||||
pub fn melt(&mut self, invoice: String, cx: &mut Context<Self>) {
|
pub fn melt(&mut self, invoice: String, cx: &mut Context<Self>) {
|
||||||
let Some(mint_url) = self.primary_mint(cx) else {
|
let Some(mint_url) = self.primary_mint(cx) else {
|
||||||
@@ -896,7 +906,11 @@ impl WalletRegistry {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match outcome {
|
match outcome {
|
||||||
ops::Outcome::Idle => {}
|
ops::Outcome::Idle => {
|
||||||
|
if matches!(job, ops::Job::ClaimQuote { .. }) {
|
||||||
|
self.emit_error("Invoice is not paid yet".to_string(), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
ops::Outcome::Reconciled { updates } => {
|
ops::Outcome::Reconciled { updates } => {
|
||||||
self.publish_failed = false;
|
self.publish_failed = false;
|
||||||
let changed = !updates.is_empty();
|
let changed = !updates.is_empty();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use ::nip60::{HistoryEntry, WalletEvent, WalletRegistry};
|
use ::nip60::{HistoryEntry, MintQuoteEntry, WalletEvent, WalletRegistry};
|
||||||
use common::TimestampExt;
|
use common::TimestampExt;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -13,6 +13,7 @@ use gpui::{
|
|||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||||
|
use gpui_kit::component::clipboard::Clipboard;
|
||||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||||
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
|
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
@@ -108,6 +109,111 @@ impl WalletPanel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_quotes(&self, quotes: Vec<MintQuoteEntry>, cx: &Context<Self>) -> Option<AnyElement> {
|
||||||
|
if quotes.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child("Pending invoices"),
|
||||||
|
)
|
||||||
|
.children(
|
||||||
|
quotes
|
||||||
|
.iter()
|
||||||
|
.map(|quote| self.render_quote_entry(quote, cx)),
|
||||||
|
)
|
||||||
|
.into_any_element(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_quote_entry(&self, quote: &MintQuoteEntry, cx: &Context<Self>) -> impl IntoElement {
|
||||||
|
let wallet = WalletRegistry::global(cx);
|
||||||
|
let quote_id = quote.quote_id.clone();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.w_full()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.justify_between()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.min_w_0()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.text_color(cx.theme().success)
|
||||||
|
.child(SharedString::from(format!("{} sats", quote.amount))),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.truncate()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(SharedString::from(quote.mint.to_string())),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
Button::new(format!("mint-quote-check-{quote_id}"))
|
||||||
|
.label("Check")
|
||||||
|
.tooltip("Check payment and claim")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click({
|
||||||
|
let wallet = wallet.clone();
|
||||||
|
let quote_id = quote_id.clone();
|
||||||
|
move |_, _, cx| {
|
||||||
|
wallet.update(cx, |registry, cx| {
|
||||||
|
registry.claim_mint_quote(quote_id.clone(), cx)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Clipboard::new(format!("mint-quote-copy-{quote_id}"))
|
||||||
|
.value(quote.request.clone())
|
||||||
|
.tooltip("Copy invoice")
|
||||||
|
.small(),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new(format!("mint-quote-dismiss-{quote_id}"))
|
||||||
|
.icon(IconName::Close)
|
||||||
|
.tooltip("Dismiss")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click({
|
||||||
|
let wallet = wallet.clone();
|
||||||
|
move |_, _, cx| {
|
||||||
|
wallet.update(cx, |registry, cx| {
|
||||||
|
registry.dismiss_mint_quote(quote_id.clone(), cx)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.overflow_hidden()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.text_ellipsis_middle()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(SharedString::from(quote.request.clone())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn render_history(
|
fn render_history(
|
||||||
&mut self,
|
&mut self,
|
||||||
history: Vec<HistoryEntry>,
|
history: Vec<HistoryEntry>,
|
||||||
@@ -762,7 +868,9 @@ impl Focusable for WalletPanel {
|
|||||||
|
|
||||||
impl Render for WalletPanel {
|
impl Render for WalletPanel {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let history = WalletRegistry::global(cx).read(cx).history().to_vec();
|
let wallet = WalletRegistry::global(cx);
|
||||||
|
let history = wallet.read(cx).history().to_vec();
|
||||||
|
let quotes = wallet.read(cx).mint_quotes().to_vec();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -771,6 +879,7 @@ impl Render for WalletPanel {
|
|||||||
.text_xs()
|
.text_xs()
|
||||||
.child(self.render_balance(cx))
|
.child(self.render_balance(cx))
|
||||||
.child(self.render_actions())
|
.child(self.render_actions())
|
||||||
|
.children(self.render_quotes(quotes, cx))
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
|
|||||||
+45
-20
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::RwLock;
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use common::emojis_from_tags;
|
use common::emojis_from_tags;
|
||||||
@@ -64,7 +64,7 @@ enum Dispatch {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PersonRegistry {
|
pub struct PersonRegistry {
|
||||||
/// Public keys with a metadata request in flight
|
/// Public keys with a metadata request in flight
|
||||||
in_flight: RwLock<HashSet<PublicKey>>,
|
in_flight: Arc<RwLock<HashSet<PublicKey>>>,
|
||||||
/// Web of trust graph of the signed-in user
|
/// Web of trust graph of the signed-in user
|
||||||
wot: Entity<WebOfTrust>,
|
wot: Entity<WebOfTrust>,
|
||||||
sender: flume::Sender<PublicKey>,
|
sender: flume::Sender<PublicKey>,
|
||||||
@@ -102,8 +102,12 @@ impl PersonRegistry {
|
|||||||
|
|
||||||
let client3 = client.clone();
|
let client3 = client.clone();
|
||||||
let executor = cx.background_executor().clone();
|
let executor = cx.background_executor().clone();
|
||||||
|
|
||||||
|
let in_flight = Arc::new(RwLock::new(HashSet::new()));
|
||||||
|
let in_flight_requests = Arc::clone(&in_flight);
|
||||||
|
|
||||||
tasks.push(cx.background_spawn(async move {
|
tasks.push(cx.background_spawn(async move {
|
||||||
Self::handle_requests(&client3, &metadata_rx, executor).await;
|
Self::handle_requests(&client3, &metadata_rx, executor, &in_flight_requests).await;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
tasks.push(cx.spawn(async move |this, cx| {
|
tasks.push(cx.spawn(async move |this, cx| {
|
||||||
@@ -140,7 +144,7 @@ impl PersonRegistry {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
in_flight: RwLock::new(HashSet::new()),
|
in_flight,
|
||||||
wot,
|
wot,
|
||||||
sender: metadata_tx,
|
sender: metadata_tx,
|
||||||
tasks,
|
tasks,
|
||||||
@@ -205,6 +209,7 @@ impl PersonRegistry {
|
|||||||
client: &Client,
|
client: &Client,
|
||||||
rx: &flume::Receiver<PublicKey>,
|
rx: &flume::Receiver<PublicKey>,
|
||||||
executor: BackgroundExecutor,
|
executor: BackgroundExecutor,
|
||||||
|
in_flight: &RwLock<HashSet<PublicKey>>,
|
||||||
) {
|
) {
|
||||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||||
|
|
||||||
@@ -216,34 +221,44 @@ impl PersonRegistry {
|
|||||||
result = rx.recv_async().fuse() => match result {
|
result = rx.recv_async().fuse() => match result {
|
||||||
Ok(public_key) => public_key,
|
Ok(public_key) => public_key,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
if !batch.is_empty()
|
Self::flush_batch(client, &mut batch, in_flight).await;
|
||||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
|
||||||
{
|
|
||||||
log::warn!("Failed to get metadata batch: {e}");
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
() = timer => {
|
() = timer => {
|
||||||
if !batch.is_empty()
|
Self::flush_batch(client, &mut batch, in_flight).await;
|
||||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
|
||||||
{
|
|
||||||
log::warn!("Failed to get metadata batch: {e}");
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
batch.insert(request);
|
batch.insert(request);
|
||||||
// Process the batch if it's full
|
// Process the batch if it's full
|
||||||
if batch.len() >= 20
|
if batch.len() >= 20 {
|
||||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
Self::flush_batch(client, &mut batch, in_flight).await;
|
||||||
{
|
|
||||||
log::warn!("Failed to get metadata batch: {e}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send one metadata request for the batch, releasing its keys for retry on failure
|
||||||
|
async fn flush_batch(
|
||||||
|
client: &Client,
|
||||||
|
batch: &mut HashSet<PublicKey>,
|
||||||
|
in_flight: &RwLock<HashSet<PublicKey>>,
|
||||||
|
) {
|
||||||
|
let batch = std::mem::take(batch);
|
||||||
|
if batch.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) = get_metadata(client, batch.iter().cloned()).await {
|
||||||
|
Self::release_keys(in_flight, batch.iter().cloned());
|
||||||
|
log::warn!(
|
||||||
|
"Failed to get metadata for {} public keys, released for retry: {error}",
|
||||||
|
batch.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Subscribe to the signed-in user's emoji list
|
/// Subscribe to the signed-in user's emoji list
|
||||||
fn get_emojis(&mut self, cx: &mut Context<Self>) {
|
fn get_emojis(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -334,11 +349,21 @@ impl PersonRegistry {
|
|||||||
|
|
||||||
/// Allow future fetches for a key once its metadata has arrived
|
/// Allow future fetches for a key once its metadata has arrived
|
||||||
fn release_in_flight(&self, public_key: &PublicKey) {
|
fn release_in_flight(&self, public_key: &PublicKey) {
|
||||||
let mut in_flight = match self.in_flight.write() {
|
Self::release_keys(&self.in_flight, [*public_key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the given keys from the in-flight set
|
||||||
|
fn release_keys(
|
||||||
|
in_flight: &RwLock<HashSet<PublicKey>>,
|
||||||
|
keys: impl IntoIterator<Item = PublicKey>,
|
||||||
|
) {
|
||||||
|
let mut in_flight = match in_flight.write() {
|
||||||
Ok(in_flight) => in_flight,
|
Ok(in_flight) => in_flight,
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
};
|
};
|
||||||
in_flight.remove(public_key);
|
for public_key in keys {
|
||||||
|
in_flight.remove(&public_key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ impl NewChat {
|
|||||||
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
this.submit(window, cx)
|
this.submit(window, cx)
|
||||||
|
} else if matches!(event, InputEvent::Change) {
|
||||||
|
// The contact list only re-filters when the dialog re-renders
|
||||||
|
cx.notify();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,17 @@ use chat::{ChatRegistry, Room, RoomKind};
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
|
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||||
Styled, Task, Window, div, retain_all,
|
Styled, Subscription, Task, Window, div, retain_all,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::clipboard::Clipboard;
|
use gpui_kit::component::clipboard::Clipboard;
|
||||||
use gpui_kit::component::dock::DockPlacement;
|
use gpui_kit::component::dock::DockPlacement;
|
||||||
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState};
|
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
|
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
@@ -51,13 +52,19 @@ struct ProfileDialog {
|
|||||||
uploading: bool,
|
uploading: bool,
|
||||||
/// Updating state
|
/// Updating state
|
||||||
updating: bool,
|
updating: bool,
|
||||||
|
/// Error message
|
||||||
|
error: Option<SharedString>,
|
||||||
/// Tasks
|
/// Tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
/// Keeps the avatar preview live while the URL is typed
|
||||||
|
_subscription: Option<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProfileDialog {
|
impl ProfileDialog {
|
||||||
fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let directory = Directory::new(cx);
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
let is_current_user = nostr
|
let is_current_user = nostr
|
||||||
.read(cx)
|
.read(cx)
|
||||||
.current_user()
|
.current_user()
|
||||||
@@ -75,7 +82,17 @@ impl ProfileDialog {
|
|||||||
.placeholder("A short introduce about you.")
|
.placeholder("A short introduce about you.")
|
||||||
});
|
});
|
||||||
|
|
||||||
let directory = Directory::new(cx);
|
let subscription = cx.subscribe_in(
|
||||||
|
&avatar_input,
|
||||||
|
window,
|
||||||
|
|_this, _avatar_input, event, _window, cx| {
|
||||||
|
if matches!(event, InputEvent::Change) {
|
||||||
|
// The avatar preview only re-renders when the dialog does
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
directory.update(cx, |directory, cx| {
|
directory.update(cx, |directory, cx| {
|
||||||
directory.track([public_key], cx);
|
directory.track([public_key], cx);
|
||||||
});
|
});
|
||||||
@@ -91,7 +108,9 @@ impl ProfileDialog {
|
|||||||
website_input,
|
website_input,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
updating: false,
|
updating: false,
|
||||||
|
error: None,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
|
_subscription: Some(subscription),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +209,26 @@ impl ProfileDialog {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
E: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.error = Some(error.into());
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
|
// Clear the error message after a delay
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.error = None;
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the metadata for the current user
|
/// Set the metadata for the current user
|
||||||
fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
|
fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -218,19 +257,51 @@ impl ProfileDialog {
|
|||||||
let bio = self.bio_input.read(cx).value();
|
let bio = self.bio_input.read(cx).value();
|
||||||
let website = self.website_input.read(cx).value();
|
let website = self.website_input.read(cx).value();
|
||||||
|
|
||||||
|
// Validate both URLs before publishing anything so a partial profile is never sent
|
||||||
|
let avatar_url = if avatar.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match Url::from_str(&avatar) {
|
||||||
|
Ok(url) => Some(url),
|
||||||
|
Err(_) => {
|
||||||
|
self.set_error(
|
||||||
|
"Avatar URL is invalid. Include the full address, e.g. https://example.com/avatar.jpg",
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let website_url = if website.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match Url::from_str(&website) {
|
||||||
|
Ok(url) => Some(url),
|
||||||
|
Err(_) => {
|
||||||
|
self.set_error(
|
||||||
|
"Website URL is invalid. Include the full address, e.g. https://example.com",
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Construct the new metadata
|
// Construct the new metadata
|
||||||
let mut new_metadata = old_metadata
|
let mut new_metadata = old_metadata
|
||||||
.display_name(name.as_ref())
|
.display_name(name.as_ref())
|
||||||
.name(name.as_ref())
|
.name(name.as_ref())
|
||||||
.about(bio.as_ref());
|
.about(bio.as_ref());
|
||||||
|
|
||||||
// Verify the avatar URL before adding it
|
// An empty input keeps the saved value, a valid URL replaces it
|
||||||
if let Ok(url) = Url::from_str(&avatar) {
|
if let Some(url) = avatar_url {
|
||||||
new_metadata = new_metadata.picture(url);
|
new_metadata = new_metadata.picture(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the website URL before adding it
|
if let Some(url) = website_url {
|
||||||
if let Ok(url) = Url::from_str(&website) {
|
|
||||||
new_metadata = new_metadata.website(url);
|
new_metadata = new_metadata.website(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +500,15 @@ impl Render for ProfileDialog {
|
|||||||
this.child(field("Web of Trust", score, "Not loaded yet", cx))
|
this.child(field("Web of Trust", score, "Not loaded yet", cx))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
.when_some(self.error.as_ref(), |this, error| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.italic()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().danger)
|
||||||
|
.child(error.clone()),
|
||||||
|
)
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use gpui::{
|
|||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
|
use gpui_kit::component::notification::Notification;
|
||||||
use gpui_kit::component::spinner::Spinner;
|
use gpui_kit::component::spinner::Spinner;
|
||||||
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
@@ -23,11 +24,23 @@ pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<
|
|||||||
cx.new(|cx| Screening::new(public_key, window, cx))
|
cx.new(|cx| Screening::new(public_key, window, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Outcome of verifying the person's NIP-05 address
|
||||||
|
enum IdentifierStatus {
|
||||||
|
/// The check is still running
|
||||||
|
Pending,
|
||||||
|
/// The address matches the person's public key
|
||||||
|
Verified,
|
||||||
|
/// The address does not match the person's public key
|
||||||
|
Mismatched,
|
||||||
|
/// The check could not run, e.g. the network is unreachable
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Screening {
|
pub struct Screening {
|
||||||
/// Public Key of the person being screened.
|
/// Public Key of the person being screened.
|
||||||
public_key: PublicKey,
|
public_key: PublicKey,
|
||||||
/// Whether the person's address is verified.
|
/// Outcome of the NIP-05 address verification
|
||||||
verified: bool,
|
identifier_status: IdentifierStatus,
|
||||||
/// Whether the person is followed by current user.
|
/// Whether the person is followed by current user.
|
||||||
followed: bool,
|
followed: bool,
|
||||||
/// Last time the person was active.
|
/// Last time the person was active.
|
||||||
@@ -49,9 +62,8 @@ impl Screening {
|
|||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
|
subscriptions.push(cx.on_release_in(window, move |this, _window, _cx| {
|
||||||
this.tasks.clear();
|
this.tasks.clear();
|
||||||
window.close_all_dialogs(cx);
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| {
|
subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| {
|
||||||
@@ -71,7 +83,7 @@ impl Screening {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
public_key,
|
public_key,
|
||||||
verified: false,
|
identifier_status: IdentifierStatus::Pending,
|
||||||
followed: false,
|
followed: false,
|
||||||
last_active: None,
|
last_active: None,
|
||||||
mutual_contacts: vec![],
|
mutual_contacts: vec![],
|
||||||
@@ -221,10 +233,17 @@ impl Screening {
|
|||||||
cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
|
cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let result = task.await.unwrap_or(false);
|
let result = task.await;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.verified = result;
|
this.identifier_status = match result {
|
||||||
|
Ok(true) => IdentifierStatus::Verified,
|
||||||
|
Ok(false) => IdentifierStatus::Mismatched,
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("failed to verify NIP-05 address: {error:#}");
|
||||||
|
IdentifierStatus::Failed
|
||||||
|
}
|
||||||
|
};
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
@@ -270,14 +289,26 @@ impl Screening {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |_, cx| {
|
self.tasks
|
||||||
if task.await.is_ok() {
|
.push(cx.spawn_in(window, async move |_, cx| match task.await {
|
||||||
|
Ok(_) => {
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
window.close_dialog(cx);
|
window.close_dialog(cx);
|
||||||
window.push_notification("Report submitted successfully", cx);
|
window.push_notification("Report submitted successfully", cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("failed to submit report: {error:#}");
|
||||||
|
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(error.to_string()).autohide(false),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +372,9 @@ impl Render for Screening {
|
|||||||
const NO_MUTUAL: &str = "You don't have any mutual contacts.";
|
const NO_MUTUAL: &str = "You don't have any mutual contacts.";
|
||||||
const NIP05_MATCH: &str = "The address matches the user's public key.";
|
const NIP05_MATCH: &str = "The address matches the user's public key.";
|
||||||
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
|
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
|
||||||
|
const NIP05_PENDING: &str = "Checking the address…";
|
||||||
|
const NIP05_FAILED: &str =
|
||||||
|
"The address could not be verified, the network may be unavailable.";
|
||||||
const NO_NIP05: &str = "This person has not set up their friendly address";
|
const NO_NIP05: &str = "This person has not set up their friendly address";
|
||||||
|
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
@@ -349,6 +383,20 @@ impl Render for Screening {
|
|||||||
|
|
||||||
let last_active = self.last_active.map(|_| true);
|
let last_active = self.last_active.map(|_| true);
|
||||||
|
|
||||||
|
let (identifier_badge, identifier_message) = match self.identifier_status {
|
||||||
|
IdentifierStatus::Pending => (None, NIP05_PENDING),
|
||||||
|
IdentifierStatus::Verified => (Some(true), NIP05_MATCH),
|
||||||
|
IdentifierStatus::Mismatched => (Some(false), NIP05_NOT_MATCH),
|
||||||
|
IdentifierStatus::Failed => (Some(false), NIP05_FAILED),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Without an address the check never runs
|
||||||
|
let (identifier_badge, identifier_message) = if self.address(cx).is_some() {
|
||||||
|
(identifier_badge, identifier_message)
|
||||||
|
} else {
|
||||||
|
(Some(false), NO_NIP05)
|
||||||
|
};
|
||||||
|
|
||||||
// Pull from the web of trust once loaded, else use the database fallback
|
// Pull from the web of trust once loaded, else use the database fallback
|
||||||
let mutual_contacts = {
|
let mutual_contacts = {
|
||||||
let wot = persons.read(cx).wot().read(cx);
|
let wot = persons.read(cx).wot().read(cx);
|
||||||
@@ -491,7 +539,7 @@ impl Render for Screening {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.items_start()
|
.items_start()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.child(status_badge(Some(self.verified), cx))
|
.child(status_badge(identifier_badge, cx))
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
@@ -508,17 +556,7 @@ impl Render for Screening {
|
|||||||
div()
|
div()
|
||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.child({
|
.child(SharedString::from(identifier_message)),
|
||||||
if self.address(cx).is_some() {
|
|
||||||
if self.verified {
|
|
||||||
SharedString::from(NIP05_MATCH)
|
|
||||||
} else {
|
|
||||||
SharedString::from(NIP05_NOT_MATCH)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
SharedString::from(NO_NIP05)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use gpui::{App, Pixels, WeakEntity, Window, px};
|
use chat_ui::ChatPanel;
|
||||||
use gpui_kit::component::dock::{
|
use gpui::{App, Entity, Pixels, SharedString, WeakEntity, Window, px};
|
||||||
DockArea, DockPlacement, InsertTarget, NodeId, PaneRef, PanelHandle, PanelId,
|
use gpui_base::dock::PanelView;
|
||||||
};
|
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle, PanelId};
|
||||||
|
use nip29::GroupKey;
|
||||||
|
use nip29_ui::GroupPanel;
|
||||||
|
|
||||||
mod invalid_panel;
|
mod invalid_panel;
|
||||||
mod skin;
|
mod skin;
|
||||||
@@ -14,39 +16,83 @@ pub use skin::CoopDockSkin;
|
|||||||
|
|
||||||
/// The fixed height of the tab bar, which doubles as the window title bar.
|
/// The fixed height of the tab bar, which doubles as the window title bar.
|
||||||
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
|
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
|
||||||
|
/// Dock placements.
|
||||||
/// The panel in any region of `area whose id is `key.
|
const DOCK_PLACEMENTS: [DockPlacement; 4] = [
|
||||||
fn find_panel(area: &DockArea, key: PanelId, _cx: &App) -> Option<(PanelId, NodeId, usize)> {
|
|
||||||
let placements = [
|
|
||||||
DockPlacement::Center,
|
DockPlacement::Center,
|
||||||
DockPlacement::Left,
|
DockPlacement::Left,
|
||||||
DockPlacement::Right,
|
DockPlacement::Right,
|
||||||
DockPlacement::Bottom,
|
DockPlacement::Bottom,
|
||||||
];
|
];
|
||||||
|
|
||||||
for placement in placements {
|
/// What makes a panel the same panel across opens.
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
enum PanelKey {
|
||||||
|
/// A panel of which there should be one, keyed by its `panel_name`.
|
||||||
|
Named(&'static str),
|
||||||
|
/// A chat panel, keyed by the id of the room it displays.
|
||||||
|
Room(SharedString),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key a panel handle carries, or `None` when it cannot be read off the panel.
|
||||||
|
fn panel_key(panel: &PanelHandle, cx: &App) -> Option<PanelKey> {
|
||||||
|
let view = panel.panel();
|
||||||
|
if let Some(chat) = view.as_any().downcast_ref::<Entity<ChatPanel>>() {
|
||||||
|
return Some(PanelKey::Room(chat.read(cx).room_id().clone()));
|
||||||
|
}
|
||||||
|
if view.as_any().downcast_ref::<Entity<GroupPanel>>().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(PanelKey::Named(view.panel_name(cx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key of a panel docked in `area`, recovered through the handle base holds.
|
||||||
|
fn docked_panel_key(view: &Arc<dyn PanelView>, cx: &App) -> Option<PanelKey> {
|
||||||
|
let handle = PanelHandle::of(view)?;
|
||||||
|
panel_key(handle, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The docked panel whose key is `key`, and the dock it sits in.
|
||||||
|
fn find_panel(area: &DockArea, key: &PanelKey, cx: &App) -> Option<(PanelId, DockPlacement)> {
|
||||||
|
for placement in DOCK_PLACEMENTS {
|
||||||
let Some(tree) = area.layout(placement) else {
|
let Some(tree) = area.layout(placement) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
for id in tree.panels() {
|
for id in tree.panels() {
|
||||||
if id != key {
|
let Some(view) = area.panel(id) else {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let node = tree.find_panel_node(id)?;
|
|
||||||
let PaneRef::Tabs { panels, .. } = tree.find_node(node)?.kind() else {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let ix = panels.iter().position(|panel| *panel == key)?;
|
if docked_panel_key(view, cx).is_some_and(|found| &found == key) {
|
||||||
return Some((id, node, ix));
|
return Some((id, placement));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add `panel to the area, or move it to the end of its existing tab group.
|
/// Bring the docked panel `id` on screen.
|
||||||
|
pub fn focus_panel(
|
||||||
|
area: &mut DockArea,
|
||||||
|
id: PanelId,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut gpui::Context<DockArea>,
|
||||||
|
) {
|
||||||
|
let Some(placement) = DOCK_PLACEMENTS.into_iter().find(|placement| {
|
||||||
|
area.layout(*placement)
|
||||||
|
.is_some_and(|tree| tree.panels().any(|panel| panel == id))
|
||||||
|
}) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The center region has no open state, and `toggle_dock` would ignore it.
|
||||||
|
if placement != DockPlacement::Center && !area.is_dock_open(placement) {
|
||||||
|
area.toggle_dock(placement, window, cx);
|
||||||
|
}
|
||||||
|
area.select_panel(id, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add `panel` to the area, or focus the panel already showing the same thing.
|
||||||
pub fn add_panel(
|
pub fn add_panel(
|
||||||
area: &mut DockArea,
|
area: &mut DockArea,
|
||||||
panel: PanelHandle,
|
panel: PanelHandle,
|
||||||
@@ -54,24 +100,44 @@ pub fn add_panel(
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut gpui::Context<DockArea>,
|
cx: &mut gpui::Context<DockArea>,
|
||||||
) {
|
) {
|
||||||
let key = panel.panel_id(cx);
|
let Some(key) = panel_key(&panel, cx) else {
|
||||||
if let Some((id, node, ix)) = find_panel(area, key, cx) {
|
area.add_panel_view(Arc::new(panel), placement, None, window, cx);
|
||||||
area.move_panel(
|
return;
|
||||||
id,
|
};
|
||||||
InsertTarget::Tabs {
|
|
||||||
node,
|
if let Some((id, _)) = find_panel(area, &key, cx) {
|
||||||
ix: Some(ix),
|
focus_panel(area, id, window, cx);
|
||||||
activate: true,
|
|
||||||
},
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
area.add_panel_view(Arc::new(panel), placement, None, window, cx);
|
area.add_panel_view(Arc::new(panel), placement, None, window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Close the docked chat panel showing `room_id`, if there is one.
|
||||||
|
pub fn close_room_panel(
|
||||||
|
area: &mut DockArea,
|
||||||
|
room_id: &SharedString,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut gpui::Context<DockArea>,
|
||||||
|
) {
|
||||||
|
let key = PanelKey::Room(room_id.clone());
|
||||||
|
|
||||||
|
let Some((id, _)) = find_panel(area, &key, cx) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(view) = area.panel(id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(chat) = view.as_any().downcast_ref::<Entity<ChatPanel>>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let chat = chat.clone();
|
||||||
|
area.remove_panel(chat, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a panel to a dock reached through a weak handle.
|
/// Add a panel to a dock reached through a weak handle.
|
||||||
pub fn add_panel_to(
|
pub fn add_panel_to(
|
||||||
dock: &WeakEntity<DockArea>,
|
dock: &WeakEntity<DockArea>,
|
||||||
@@ -86,3 +152,25 @@ pub fn add_panel_to(
|
|||||||
log::warn!("Failed to add panel to dock: {error}");
|
log::warn!("Failed to add panel to dock: {error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a group panel to a dock, or focus the panel already showing that group.
|
||||||
|
///
|
||||||
|
/// Group panels hide their group, so the workspace records which panel it
|
||||||
|
/// docked per key; without that record, reopening a group stacks another tab.
|
||||||
|
pub fn add_group_panel_to(
|
||||||
|
dock: &WeakEntity<DockArea>,
|
||||||
|
group_key: GroupKey,
|
||||||
|
panel: PanelHandle,
|
||||||
|
placement: DockPlacement,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let Some(root) = window.root::<crate::Workspace>().flatten() else {
|
||||||
|
add_panel_to(dock, panel, placement, window, cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
root.update(cx, |this, cx| {
|
||||||
|
this.add_group_panel_to_dock(group_key, panel, placement, window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ use super::tab_panel::CoopTabGroupSkin;
|
|||||||
/// State the skin shares with its per-container renderers.
|
/// State the skin shares with its per-container renderers.
|
||||||
pub(crate) struct SkinShared {
|
pub(crate) struct SkinShared {
|
||||||
area: WeakEntity<DockArea>,
|
area: WeakEntity<DockArea>,
|
||||||
toggle_button_visible: Cell<bool>,
|
|
||||||
/// The dock whose resize handle is being dragged, if any. Only one can be.
|
/// The dock whose resize handle is being dragged, if any. Only one can be.
|
||||||
resizing_dock: Cell<Option<DockPlacement>>,
|
resizing_dock: Cell<Option<DockPlacement>>,
|
||||||
}
|
}
|
||||||
@@ -32,10 +31,6 @@ impl SkinShared {
|
|||||||
&self.area
|
&self.area
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_toggle_button_visible(&self) -> bool {
|
|
||||||
self.toggle_button_visible.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
|
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
|
||||||
&self.resizing_dock
|
&self.resizing_dock
|
||||||
}
|
}
|
||||||
@@ -52,7 +47,6 @@ impl CoopDockSkin {
|
|||||||
Rc::new(Self {
|
Rc::new(Self {
|
||||||
shared: Rc::new(SkinShared {
|
shared: Rc::new(SkinShared {
|
||||||
area: cx.weak_entity(),
|
area: cx.weak_entity(),
|
||||||
toggle_button_visible: Cell::new(true),
|
|
||||||
resizing_dock: Cell::new(None),
|
resizing_dock: Cell::new(None),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ impl CoopTabGroupSkin {
|
|||||||
group: &TabGroupContext,
|
group: &TabGroupContext,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Option<Button> {
|
) -> Option<Button> {
|
||||||
if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
|
if group.is_zoomed() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ use gpui_kit::component::button::Button;
|
|||||||
use gpui_kit::component::command::{
|
use gpui_kit::component::command::{
|
||||||
Command as CommandPalette, CommandGroup, CommandItem, CommandState,
|
Command as CommandPalette, CommandGroup, CommandItem, CommandState,
|
||||||
};
|
};
|
||||||
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle, PanelId};
|
||||||
use gpui_kit::component::notification::{Notification, NotificationType};
|
use gpui_kit::component::notification::{Notification, NotificationType};
|
||||||
use gpui_kit::component::{
|
use gpui_kit::component::{
|
||||||
ActiveTheme, IndexPath, Theme, ThemeConfig, ThemeRegistry, WindowExt, v_flex,
|
ActiveTheme, IndexPath, Theme, ThemeConfig, ThemeRegistry, WindowExt, v_flex,
|
||||||
@@ -80,6 +81,9 @@ enum Command {
|
|||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
dock: Entity<DockArea>,
|
dock: Entity<DockArea>,
|
||||||
sidebar: Entity<Sidebar>,
|
sidebar: Entity<Sidebar>,
|
||||||
|
/// Group panels docked by group key, so reopening a group focuses its panel.
|
||||||
|
/// Group panels hide their group, so the dock cannot tell them apart.
|
||||||
|
group_panels: HashMap<GroupKey, PanelId>,
|
||||||
/// Async tasks
|
/// Async tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
@@ -285,6 +289,7 @@ impl Workspace {
|
|||||||
Self {
|
Self {
|
||||||
dock,
|
dock,
|
||||||
sidebar,
|
sidebar,
|
||||||
|
group_panels: HashMap::new(),
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
@@ -319,6 +324,43 @@ impl Workspace {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_group_panel_to_dock(
|
||||||
|
&mut self,
|
||||||
|
group_key: GroupKey,
|
||||||
|
panel: PanelHandle,
|
||||||
|
placement: DockPlacement,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let panel_id = panel.panel_id(cx);
|
||||||
|
let docked = self.group_panels.get(&group_key).copied();
|
||||||
|
|
||||||
|
let mut added = false;
|
||||||
|
let mut focused = false;
|
||||||
|
|
||||||
|
self.dock.update(cx, |area, cx| {
|
||||||
|
if let Some(id) = docked
|
||||||
|
&& area.panel(id).is_some()
|
||||||
|
{
|
||||||
|
crate::dock::focus_panel(area, id, window, cx);
|
||||||
|
focused = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
crate::dock::add_panel(area, panel, placement, window, cx);
|
||||||
|
added = area.panel(panel_id).is_some();
|
||||||
|
});
|
||||||
|
|
||||||
|
if focused {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if added {
|
||||||
|
self.group_panels.insert(group_key, panel_id);
|
||||||
|
} else {
|
||||||
|
self.group_panels.remove(&group_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle command events
|
/// Handle command events
|
||||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
match command {
|
match command {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||||
ParentElement, Render, SharedString, Styled, Task, Window, div,
|
ParentElement, Render, SharedString, Styled, Task, Window, div,
|
||||||
@@ -9,6 +10,7 @@ use gpui_kit::component::dock::{Panel, PanelEvent};
|
|||||||
use gpui_kit::component::input::{Input, InputState};
|
use gpui_kit::component::input::{Input, InputState};
|
||||||
use gpui_kit::component::separator::Separator;
|
use gpui_kit::component::separator::Separator;
|
||||||
use gpui_kit::component::{ActiveTheme, v_flex};
|
use gpui_kit::component::{ActiveTheme, v_flex};
|
||||||
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::USER_KEYRING;
|
use state::USER_KEYRING;
|
||||||
|
|
||||||
@@ -23,14 +25,12 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<BackupPanel> {
|
|||||||
pub struct BackupPanel {
|
pub struct BackupPanel {
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
|
|
||||||
/// Public key input
|
/// Public key input
|
||||||
npub_input: Entity<InputState>,
|
npub_input: Entity<InputState>,
|
||||||
|
|
||||||
/// Secret key input
|
/// Secret key input
|
||||||
nsec_input: Entity<InputState>,
|
nsec_input: Entity<InputState>,
|
||||||
|
/// Error message
|
||||||
/// Background tasks
|
error: Option<SharedString>,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ impl BackupPanel {
|
|||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
npub_input,
|
npub_input,
|
||||||
nsec_input,
|
nsec_input,
|
||||||
|
error: None,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,20 +58,58 @@ impl BackupPanel {
|
|||||||
let keyring = cx.read_credentials(USER_KEYRING);
|
let keyring = cx.read_credentials(USER_KEYRING);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
if let Some((_, secret)) = keyring.await? {
|
let result: Result<(), Error> = async {
|
||||||
let secret = SecretKey::from_slice(&secret)?;
|
let Some((_, secret)) = keyring.await? else {
|
||||||
let keys = Keys::new(secret);
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let keys = Keys::new(SecretKey::from_slice(&secret)?);
|
||||||
|
|
||||||
|
// Bech32 encoding of a valid key cannot fail today, but handle it instead of panicking
|
||||||
|
let npub = keys.public_key().to_bech32()?;
|
||||||
|
let nsec = keys.secret_key().to_bech32()?;
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.npub_input.update(cx, |this, cx| {
|
this.npub_input.update(cx, |this, cx| {
|
||||||
this.set_value(keys.public_key().to_bech32().unwrap(), window, cx);
|
this.set_value(npub.as_str(), window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.nsec_input.update(cx, |this, cx| {
|
this.nsec_input.update(cx, |this, cx| {
|
||||||
this.set_value(keys.secret_key().to_bech32().unwrap(), window, cx);
|
this.set_value(nsec.as_str(), window, cx);
|
||||||
});
|
});
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(error) = result {
|
||||||
|
log::error!("failed to load account keys: {error:#}");
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_error(format!("Failed to load your keys: {error}"), window, cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
E: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.error = Some(error.into());
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
// Clear the error message after a delay
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.error = None;
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
@@ -110,6 +149,15 @@ impl Render for BackupPanel {
|
|||||||
.child(SharedString::from(MSG)),
|
.child(SharedString::from(MSG)),
|
||||||
)
|
)
|
||||||
.child(Separator::horizontal())
|
.child(Separator::horizontal())
|
||||||
|
.when_some(self.error.as_ref(), |this, error| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.italic()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().danger)
|
||||||
|
.child(error.clone()),
|
||||||
|
)
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
|
|||||||
@@ -227,10 +227,11 @@ impl BrowsePanel {
|
|||||||
/// Open the group's panel to preview (or revisit) it.
|
/// Open the group's panel to preview (or revisit) it.
|
||||||
fn open_panel(dock: &WeakEntity<DockArea>, key: GroupKey, window: &mut Window, cx: &mut App) {
|
fn open_panel(dock: &WeakEntity<DockArea>, key: GroupKey, window: &mut Window, cx: &mut App) {
|
||||||
let registry = GroupsRegistry::global(cx);
|
let registry = GroupsRegistry::global(cx);
|
||||||
let group = registry.update(cx, |registry, cx| registry.open(key, cx));
|
let group = registry.update(cx, |registry, cx| registry.open(key.clone(), cx));
|
||||||
|
|
||||||
crate::dock::add_panel_to(
|
crate::dock::add_group_panel_to(
|
||||||
dock,
|
dock,
|
||||||
|
key,
|
||||||
PanelHandle::new(nip29_ui::init(group, window, cx)),
|
PanelHandle::new(nip29_ui::init(group, window, cx)),
|
||||||
DockPlacement::Center,
|
DockPlacement::Center,
|
||||||
window,
|
window,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use auto_update::AutoUpdater;
|
use auto_update::{AutoUpdater, UpdateDisplay};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui::AnyElement;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Anchor, App, Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString,
|
Anchor, App, Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString,
|
||||||
Stateful, Styled, Window, div, px,
|
Stateful, Styled, Window, div, px,
|
||||||
};
|
};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use gpui::{AnyElement, Entity};
|
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use gpui_kit::component::Disableable;
|
use gpui_kit::component::Disableable;
|
||||||
@@ -35,7 +35,7 @@ impl Sidebar {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Stateful<Div> {
|
) -> Stateful<Div> {
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let updater = AutoUpdater::try_global(cx);
|
let updater_display = self.updater_display.read(cx).clone();
|
||||||
let profile = self.directory.read(cx).person(current_user, cx);
|
let profile = self.directory.read(cx).person(current_user, cx);
|
||||||
let avatar = profile.avatar();
|
let avatar = profile.avatar();
|
||||||
let encryption_key = profile
|
let encryption_key = profile
|
||||||
@@ -78,8 +78,8 @@ impl Sidebar {
|
|||||||
let actions = h_flex().gap_1();
|
let actions = h_flex().gap_1();
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let actions = actions.when_some(updater, |this, updater| {
|
let actions = actions.when_some(updater_display, |this, display| {
|
||||||
this.child(self.render_updater(updater, cx))
|
this.child(self.render_updater(display))
|
||||||
});
|
});
|
||||||
|
|
||||||
actions
|
actions
|
||||||
@@ -233,10 +233,12 @@ impl Sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
|
fn render_updater(&self, display: UpdateDisplay) -> AnyElement {
|
||||||
let status = updater.read(cx).status();
|
let UpdateDisplay {
|
||||||
let up_to_date = updater.read(cx).up_to_date();
|
up_to_date,
|
||||||
let staged = updater.read(cx).staged();
|
staged,
|
||||||
|
status,
|
||||||
|
} = display;
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -257,10 +259,12 @@ impl Sidebar {
|
|||||||
.tooltip("Quit and relaunch into the installed update")
|
.tooltip("Quit and relaunch into the installed update")
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
.on_click(move |_, _window, cx| {
|
.on_click(|_, _window, cx| {
|
||||||
|
if let Some(updater) = AutoUpdater::try_global(cx) {
|
||||||
updater.update(cx, |this, cx| {
|
updater.update(cx, |this, cx| {
|
||||||
this.restart(cx);
|
this.restart(cx);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use auto_update::{AutoUpdater, UpdateDisplay};
|
||||||
use chat::{ChatEvent, ChatRegistry};
|
use chat::{ChatEvent, ChatRegistry};
|
||||||
use common::DebouncedDelay;
|
use common::DebouncedDelay;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
@@ -21,7 +23,6 @@ use nostr_sdk::prelude::*;
|
|||||||
use person::{Directory, PersonRegistry};
|
use person::{Directory, PersonRegistry};
|
||||||
pub(crate) use rows::open_room;
|
pub(crate) use rows::open_room;
|
||||||
use search::SearchResult;
|
use search::SearchResult;
|
||||||
use settings::AppSettings;
|
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
||||||
use ui::title_bar_drag_handlers;
|
use ui::title_bar_drag_handlers;
|
||||||
@@ -44,7 +45,6 @@ pub struct Sidebar {
|
|||||||
dock: WeakEntity<DockArea>,
|
dock: WeakEntity<DockArea>,
|
||||||
/// Random background shown behind the signed-out screen.
|
/// Random background shown behind the signed-out screen.
|
||||||
banner: SharedString,
|
banner: SharedString,
|
||||||
|
|
||||||
/// The group whose information the sidebar is browsing, if any.
|
/// The group whose information the sidebar is browsing, if any.
|
||||||
group: Option<WeakEntity<Group>>,
|
group: Option<WeakEntity<Group>>,
|
||||||
/// Whether the sidebar is showing the group's information instead of its list.
|
/// Whether the sidebar is showing the group's information instead of its list.
|
||||||
@@ -53,14 +53,12 @@ pub struct Sidebar {
|
|||||||
admins_open: bool,
|
admins_open: bool,
|
||||||
/// Whether the group view's members section is expanded.
|
/// Whether the group view's members section is expanded.
|
||||||
members_open: bool,
|
members_open: bool,
|
||||||
|
|
||||||
/// Whether the main list's pinned section is expanded.
|
/// Whether the main list's pinned section is expanded.
|
||||||
pins_open: bool,
|
pins_open: bool,
|
||||||
/// Whether the user expanded or collapsed the main list's groups section.
|
/// Whether the user expanded or collapsed the main list's groups section.
|
||||||
groups_open: Option<bool>,
|
groups_open: Option<bool>,
|
||||||
/// Same as `groups_open` for the chats section.
|
/// Same as `groups_open` for the chats section.
|
||||||
chats_open: Option<bool>,
|
chats_open: Option<bool>,
|
||||||
|
|
||||||
/// Whether the main list is showing contacts to start a chat with.
|
/// Whether the main list is showing contacts to start a chat with.
|
||||||
contacts_open: bool,
|
contacts_open: bool,
|
||||||
/// The signed-in user's contacts, loaded when the contact picker opens.
|
/// The signed-in user's contacts, loaded when the contact picker opens.
|
||||||
@@ -69,11 +67,9 @@ pub struct Sidebar {
|
|||||||
directory: Entity<Directory>,
|
directory: Entity<Directory>,
|
||||||
/// Contacts selected in the contact picker.
|
/// Contacts selected in the contact picker.
|
||||||
selected_contacts: HashSet<PublicKey>,
|
selected_contacts: HashSet<PublicKey>,
|
||||||
|
|
||||||
/// Whether the main list is showing chat requests instead of chats and groups.
|
/// Whether the main list is showing chat requests instead of chats and groups.
|
||||||
requests_open: bool,
|
requests_open: bool,
|
||||||
new_requests: bool,
|
new_requests: bool,
|
||||||
|
|
||||||
/// Whether the search bar is shown below the header.
|
/// Whether the search bar is shown below the header.
|
||||||
search_open: bool,
|
search_open: bool,
|
||||||
/// Whether searches also cover global (relay) profiles.
|
/// Whether searches also cover global (relay) profiles.
|
||||||
@@ -90,12 +86,13 @@ pub struct Sidebar {
|
|||||||
global_results: Option<Vec<PublicKey>>,
|
global_results: Option<Vec<PublicKey>>,
|
||||||
/// The async global profile search.
|
/// The async global profile search.
|
||||||
global_task: Option<Task<()>>,
|
global_task: Option<Task<()>>,
|
||||||
|
|
||||||
/// Simulated gift wrap processing progress, shown while the backlog loads
|
/// Simulated gift wrap processing progress, shown while the backlog loads
|
||||||
chat_progress: f32,
|
chat_progress: f32,
|
||||||
/// Simulated web of trust sync progress
|
/// Simulated web of trust sync progress
|
||||||
sync_progress: f32,
|
sync_progress: f32,
|
||||||
|
/// The updater facts the header renders, mirrored so updater churn cannot invalidate the cached sidebar
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
updater_display: Entity<Option<UpdateDisplay>>,
|
||||||
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
||||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||||
}
|
}
|
||||||
@@ -105,7 +102,6 @@ impl Sidebar {
|
|||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let groups = GroupsRegistry::global(cx);
|
let groups = GroupsRegistry::global(cx);
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let settings = AppSettings::global(cx);
|
|
||||||
let directory = Directory::new(cx);
|
let directory = Directory::new(cx);
|
||||||
|
|
||||||
let find_input = cx.new(|cx| {
|
let find_input = cx.new(|cx| {
|
||||||
@@ -209,10 +205,23 @@ impl Sidebar {
|
|||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
// The user header reads settings and the tracked profiles on render, so
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
// the encryption button reacts to toggles and announcement arrivals
|
let updater_display =
|
||||||
subscriptions.push(cx.observe(&settings, |_, _, cx| cx.notify()));
|
cx.new(|cx| AutoUpdater::try_global(cx).map(|updater| updater.read(cx).display()));
|
||||||
subscriptions.push(cx.observe(&directory, |_, _, cx| cx.notify()));
|
|
||||||
|
// Mirror only the header's rendered facts; updater churn must not invalidate the whole cached sidebar
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
if let Some(updater) = AutoUpdater::try_global(cx) {
|
||||||
|
subscriptions.push(cx.observe(&updater, |this, updater, cx| {
|
||||||
|
let display = updater.read(cx).display();
|
||||||
|
this.updater_display.update(cx, |state, cx| {
|
||||||
|
if state.as_ref() != Some(&display) {
|
||||||
|
*state = Some(display);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
tasks.push(cx.spawn(async move |this, cx| {
|
tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let timer = Duration::from_millis(PROGRESS_TICK_MS);
|
let timer = Duration::from_millis(PROGRESS_TICK_MS);
|
||||||
@@ -251,6 +260,8 @@ impl Sidebar {
|
|||||||
global_task: None,
|
global_task: None,
|
||||||
chat_progress: 0.0,
|
chat_progress: 0.0,
|
||||||
sync_progress: 0.0,
|
sync_progress: 0.0,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
updater_display,
|
||||||
tasks,
|
tasks,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::dialog::DialogButtonProps;
|
use gpui_kit::component::dialog::DialogButtonProps;
|
||||||
use gpui_kit::component::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
|
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
|
||||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||||
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex};
|
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex};
|
||||||
use nip29::Group;
|
use nip29::Group;
|
||||||
@@ -30,9 +30,12 @@ pub(crate) fn open_room(
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
|
let panel = chat_ui::init(room.downgrade(), window, cx);
|
||||||
|
let room_id = panel.read(cx).room_id().clone();
|
||||||
|
|
||||||
crate::dock::add_panel_to(
|
crate::dock::add_panel_to(
|
||||||
dock,
|
dock,
|
||||||
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
|
PanelHandle::new(panel),
|
||||||
DockPlacement::Center,
|
DockPlacement::Center,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
@@ -41,8 +44,11 @@ pub(crate) fn open_room(
|
|||||||
if room.read(cx).kind != RoomKind::Ongoing && AppSettings::get_screening(cx) {
|
if room.read(cx).kind != RoomKind::Ongoing && AppSettings::get_screening(cx) {
|
||||||
let peer = room.read(cx).display_member(cx).public_key();
|
let peer = room.read(cx).display_member(cx).public_key();
|
||||||
let view = screening::init(peer, window, cx);
|
let view = screening::init(peer, window, cx);
|
||||||
|
let dock = dock.clone();
|
||||||
|
|
||||||
window.open_alert_dialog(cx, move |alert, _window, _cx| {
|
window.open_alert_dialog(cx, move |alert, _window, _cx| {
|
||||||
|
let dock = dock.clone();
|
||||||
|
let room_id = room_id.clone();
|
||||||
alert
|
alert
|
||||||
.confirm()
|
.confirm()
|
||||||
.child(view.clone())
|
.child(view.clone())
|
||||||
@@ -52,7 +58,11 @@ pub(crate) fn open_room(
|
|||||||
.ok_text("Response"),
|
.ok_text("Response"),
|
||||||
)
|
)
|
||||||
.on_cancel(move |_event, window, cx| {
|
.on_cancel(move |_event, window, cx| {
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
if let Err(error) = dock.update(cx, |area, cx| {
|
||||||
|
crate::dock::close_room_panel(area, &room_id, window, cx);
|
||||||
|
}) {
|
||||||
|
log::warn!("Failed to close the request room's tab: {error}");
|
||||||
|
}
|
||||||
true
|
true
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -318,8 +328,11 @@ impl Sidebar {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.on_click(move |_event, window, cx| {
|
.on_click(move |_event, window, cx| {
|
||||||
crate::dock::add_panel_to(
|
let group_key = group.read(cx).key().clone();
|
||||||
|
|
||||||
|
crate::dock::add_group_panel_to(
|
||||||
&dock,
|
&dock,
|
||||||
|
group_key,
|
||||||
PanelHandle::new(nip29_ui::init(group.clone(), window, cx)),
|
PanelHandle::new(nip29_ui::init(group.clone(), window, cx)),
|
||||||
DockPlacement::Center,
|
DockPlacement::Center,
|
||||||
window,
|
window,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ Date: 2026-11-12 · Revision audited: `8fa79aef` (master) · Scope: entire works
|
|||||||
|
|
||||||
> **Remediation status (2026-11-12, later the same day):** all ten P0 items from §8 have been
|
> **Remediation status (2026-11-12, later the same day):** all ten P0 items from §8 have been
|
||||||
> implemented and validated (workspace clippy clean, `cargo fmt --check` clean, 35 tests passing).
|
> implemented and validated (workspace clippy clean, `cargo fmt --check` clean, 35 tests passing).
|
||||||
> See §9 for the per-fix log.
|
> See §9 for the per-fix log. The P1 list (committed as `fd802a88` base) has also been processed —
|
||||||
|
> see §10.
|
||||||
|
|
||||||
## 1. Scope and Method
|
## 1. Scope and Method
|
||||||
|
|
||||||
@@ -905,3 +906,91 @@ Behavior changes to be aware of when testing:
|
|||||||
- nip60: reconcile now performs one NUT-08 mint round-trip when unseen relay proofs appear.
|
- nip60: reconcile now performs one NUT-08 mint round-trip when unseen relay proofs appear.
|
||||||
- Reactions render per NIP-25 replace semantics (one reaction per author per target).
|
- Reactions render per NIP-25 replace semantics (one reaction per author per target).
|
||||||
- Messages keep their line breaks; only URL-adjacent whitespace is tidied.
|
- Messages keep their line breaks; only URL-adjacent whitespace is tidied.
|
||||||
|
|
||||||
|
## 10. P1 Remediation Log (2026-11-12)
|
||||||
|
|
||||||
|
All eleven P1 items from §8 fixed on top of the P0 commit (`fd802a88`). Validation after the
|
||||||
|
full batch: `cargo clippy --workspace --all-targets` zero warnings, `cargo fmt --all --check`
|
||||||
|
clean, `cargo test --workspace` 38 passed / 0 failed. Diff: 25 files, +1072/−247.
|
||||||
|
|
||||||
|
1. **Dock panel dedup made real** (`workspace/src/dock/mod.rs`, `lib.rs`, `skin.rs`):
|
||||||
|
`PanelKey` — `Named(panel_name)` for singletons, `Room(room_id)` for chat panels — recovered
|
||||||
|
from docked panels via `PanelHandle::of` + downcasts; `add_panel` now focuses the existing
|
||||||
|
tab (reopening a collapsed dock first) instead of stacking a duplicate; group panels dedupe
|
||||||
|
through `Workspace.group_panels: HashMap<GroupKey, PanelId>` (group identity is not readable
|
||||||
|
off a `GroupPanel`), with stale ids pruned via `area.panel(id)` validation. The dead
|
||||||
|
`find_panel`/`move_panel`/`InsertTarget` machinery is deleted, along with `skin.rs`'s
|
||||||
|
write-once `toggle_button_visible`. Known residual: the panel opened by the new-group dialog
|
||||||
|
(`dialogs/new_group.rs`, still plain `add_panel_to`) isn't registered — P2.
|
||||||
|
2. **"Ignore" screening action** (`sidebar/rows.rs`, `dock/mod.rs`): closes the request room's
|
||||||
|
tab via `dock::close_room_panel` (find by room key → `DockArea::remove_panel`) instead of
|
||||||
|
dispatching `ClosePanel` into a focus path that can never reach its handler.
|
||||||
|
3. **Updater observation** (`sidebar/mod.rs`, `sidebar/header.rs`, `auto_update/src/lib.rs`):
|
||||||
|
`AutoUpdater` now exposes `UpdateDisplay` (`up_to_date`, `staged`, `status` text) via
|
||||||
|
`display()`; the Sidebar mirrors it into a small `Entity<Option<UpdateDisplay>>`, updating it
|
||||||
|
only when a rendered fact actually changes, and the header renders from that snapshot (the
|
||||||
|
restart click fetches the global entity at click time). Observing the whole updater and
|
||||||
|
notifying the entire cached sidebar was rejected as too blunt: download-progress ticks would
|
||||||
|
have invalidated the panel on every byte.
|
||||||
|
4. **Wallet invoice flow** (`nip60_ui/wallet.rs`, `nip60/src/lib.rs`): pending mint quotes render
|
||||||
|
in the panel (amount, mint, bolt11 with middle-ellipsis, copy via the house `Clipboard`
|
||||||
|
component, check/claim wired to the existing `Job::ClaimQuote` path, per-quote dismiss via the
|
||||||
|
new `dismiss_mint_quote`); claim results refresh through the existing `WalletEvent::Updated`;
|
||||||
|
an unpaid check emits "Invoice is not paid yet" (gated on `ClaimQuote` — flagged behavior
|
||||||
|
change). Receive-via-lightning is functional end-to-end.
|
||||||
|
5. **Person fetch blacklisting** (`person/src/lib.rs`): `in_flight` is now shared
|
||||||
|
(`Arc<RwLock<HashSet<_>>>`) with the batching task; all three duplicated `get_metadata` error
|
||||||
|
arms factored into `flush_batch`, which releases the whole batch for retry on failure and
|
||||||
|
logs. A 2 s timeout now costs a retry, not a session-long placeholder.
|
||||||
|
6. **Chat trash surfaced** (`chat/src/lib.rs`): the signal consumer counts *freshly* trashed
|
||||||
|
messages (BTreeSet insert dedupes reprocess attempts) and emits one batched
|
||||||
|
`ChatEvent::Error` ("N messages could not be decrypted"), which the workspace already renders
|
||||||
|
as an error notification.
|
||||||
|
7. **chat_ui list fixes** (`chat_ui/src/lib.rs`): room connect/load failures now push persistent
|
||||||
|
error notifications (`notify_failure`, `.autohide(false)`) instead of silently empty rooms;
|
||||||
|
message inserts splice at the real row (`pos + 1`, welcome row accounted for — append case is
|
||||||
|
byte-identical to the old behavior); bulk loads are O(n log n) sort-merge with a single splice
|
||||||
|
and one index rebuild, preserving the old first-arrival-wins timestamp dedup exactly.
|
||||||
|
8. **Dialog fixes** (`workspace/src/dialogs/{profile,new_chat,screening}.rs`, `panels/backup.rs`):
|
||||||
|
profile validates avatar/website URLs before publishing (inline error banner; empty still
|
||||||
|
means "keep saved value"); backup surfaces keyring/decode failures in-panel and its bech32
|
||||||
|
`unwrap`s are gone; new_chat re-filters on `InputEvent::Change` (and profile's avatar preview
|
||||||
|
tracks typing); screening no longer closes all stacked dialogs on release, NIP-05 verification
|
||||||
|
is tri-state (pending/verified/mismatched/failed — network failure no longer asserts "does not
|
||||||
|
match"), and report submission shows failures.
|
||||||
|
9. **Parser recursion** (`common/src/parser.rs`): `NostrParserIter::next` is a loop; the
|
||||||
|
pending-match fields are gone (provably passthrough); 50,000 consecutive malformed `nostr:`
|
||||||
|
URIs now cost loop iterations, not stack frames (regression tests added) — wasm tab-crash
|
||||||
|
vector closed.
|
||||||
|
10. **Stable room ids** (`common/src/event.rs`, `nip29`): `DefaultHasher` replaced by SHA-256
|
||||||
|
(`common::stable_id` 128-bit hex, `stable_id_u64` for u64-keyed consumers); `Event` and
|
||||||
|
`UnsignedEvent` `uniq_id` impls factored into one `pubkey_set_uniq_id` helper; nip29's
|
||||||
|
`GroupKey::uniq_id` and `browse_subscription` unified (verified session-local). **Room id
|
||||||
|
values change** (new hash domain) — existing cached groupings regroup per session as rumors
|
||||||
|
re-arrive; release notes should mention it. Follow-up if 128-bit ids are wanted: chat's id
|
||||||
|
fields are u64-typed; migrating to `String` ids is mechanical.
|
||||||
|
11. **nip4e lifecycle** (`nip4e/src/lib.rs`): approval has an `approving` re-entry guard (the
|
||||||
|
device secret can no longer be double-sent), toast lifecycle is correct on both outcomes
|
||||||
|
(success clears only the matching request; failure rebuilds the toast with a fresh enabled
|
||||||
|
button); rotation aborts before overwrite when load/archive fails (surfaces via
|
||||||
|
`DeviceEvent::error`); the relay listener logs dropped events and its own death instead of
|
||||||
|
stopping silently.
|
||||||
|
12. **Proxy hardening round 2** (`browser-signer-proxy/src/lib.rs`): signed events are checked
|
||||||
|
for correspondence (`event.compute_id()` vs returned id — proves field equality, covers
|
||||||
|
stale ids), closing the event-substitution hole; the response log carries id + ok/error only,
|
||||||
|
never values; request lines/headers are capped (16 KiB → 400), `Content-Length` capped
|
||||||
|
(1 MiB → 413), invalid UTF-8 → 400, and connections are dropped after 30 s (slowloris
|
||||||
|
bound).
|
||||||
|
|
||||||
|
### P1 behavior changes to be aware of when testing
|
||||||
|
|
||||||
|
- Opening an already-open room/group/panel focuses the existing tab; the "Ignore" action closes
|
||||||
|
the request room's tab.
|
||||||
|
- Wallet panel shows a "Pending invoices" section; unpaid checks report "Invoice is not paid
|
||||||
|
yet"; quotes are dismissable.
|
||||||
|
- Room groupings regenerate under a new id scheme — first launch after this change rebuilds
|
||||||
|
rooms from cached rumors (history is kept; grouping self-heals).
|
||||||
|
- Profile saves with an invalid URL now stop with an inline error instead of silently dropping
|
||||||
|
the field; NIP-05 shows "could not be verified" on network failure instead of "does not
|
||||||
|
match"; undecryptable messages and room load/connect failures produce visible notifications.
|
||||||
|
- First message-send/receive after the room-id change republishes `r` tags under the new ids.
|
||||||
|
|||||||
Reference in New Issue
Block a user