stabilize ids and harden release pipeline

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