From b91697defc9b4db5c180a71adcefd3a8baac3f09 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 14 Mar 2026 08:18:19 +0000 Subject: [PATCH] feat: add relay tracking for gift wrap events (#21) Reviewed-on: https://git.reya.su/reya/coop/pulls/21 Co-authored-by: Ren Amamiya Co-committed-by: Ren Amamiya --- crates/chat/src/lib.rs | 77 +++++++++++++++++++-------- crates/chat_ui/src/actions.rs | 1 - crates/chat_ui/src/lib.rs | 65 ++++++----------------- crates/coop/src/workspace.rs | 70 +++++++++++++++++++------ crates/device/src/lib.rs | 30 ++++++++--- crates/relay_auth/src/lib.rs | 5 +- crates/state/src/lib.rs | 99 +++++++++++++++++++++++++++++++---- crates/ui/src/notification.rs | 51 +++++++++++++----- 8 files changed, 277 insertions(+), 121 deletions(-) diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 12bd9bc..361dd96 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -15,6 +15,7 @@ use gpui::{ }; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; +use smol::lock::RwLock; use state::{DEVICE_GIFTWRAP, NostrRegistry, StateEvent, TIMEOUT, USER_GIFTWRAP}; mod message; @@ -60,9 +61,12 @@ enum Signal { /// Chat Registry #[derive(Debug)] pub struct ChatRegistry { - /// Collection of all chat rooms + /// Chat rooms rooms: Vec>, + /// Tracking events seen on which relays in the current session + seens: Arc>>>, + /// Tracking the status of unwrapping gift wrap events. tracking_flag: Arc, @@ -101,12 +105,17 @@ impl ChatRegistry { subscriptions.push( // Subscribe to the signer event cx.subscribe(&nostr, |this, _state, event, cx| { - if let StateEvent::SignerSet = event { - this.reset(cx); - this.get_rooms(cx); - this.get_contact_list(cx); - this.get_messages(cx) - } + match event { + StateEvent::SignerSet => { + this.reset(cx); + this.get_rooms(cx); + } + StateEvent::RelayConnected => { + this.get_contact_list(cx); + this.get_messages(cx) + } + _ => {} + }; }), ); @@ -119,6 +128,7 @@ impl ChatRegistry { Self { rooms: vec![], + seens: Arc::new(RwLock::new(HashMap::default())), tracking_flag: Arc::new(AtomicBool::new(false)), signal_rx: rx, signal_tx: tx, @@ -133,6 +143,7 @@ impl ChatRegistry { let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); let status = self.tracking_flag.clone(); + let seens = self.seens.clone(); let initialized_at = Timestamp::now(); let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP); @@ -148,20 +159,26 @@ impl ChatRegistry { let mut processed_events = HashSet::new(); while let Some(notification) = notifications.next().await { - let ClientNotification::Message { message, .. } = notification else { + let ClientNotification::Message { message, relay_url } = notification else { // Skip non-message notifications continue; }; match message { RelayMessage::Event { event, .. } => { + // Keep track of which relays have seen this event + { + let mut seens = seens.write().await; + seens.entry(event.id).or_default().insert(relay_url); + } + + // De-duplicate events by their ID if !processed_events.insert(event.id) { - // Skip if the event has already been processed continue; } + // Skip non-gift wrap events if event.kind != Kind::GiftWrap { - // Skip non-gift wrap events continue; } @@ -169,26 +186,21 @@ impl ChatRegistry { match extract_rumor(&client, &device_signer, event.as_ref()).await { Ok(rumor) => { if rumor.tags.is_empty() { - let error: SharedString = - "Message doesn't belong to any rooms".into(); + let error: SharedString = "No room for message".into(); tx.send_async(Signal::Error(error)).await?; } - match rumor.created_at >= initialized_at { - true => { - let new_message = NewMessage::new(event.id, rumor); - let signal = Signal::Message(new_message); + if rumor.created_at >= initialized_at { + let new_message = NewMessage::new(event.id, rumor); + let signal = Signal::Message(new_message); - tx.send_async(signal).await?; - } - false => { - status.store(true, Ordering::Release); - } + tx.send_async(signal).await?; + } else { + status.store(true, Ordering::Release); } } Err(e) => { - let error: SharedString = - format!("Failed to unwrap the gift wrap event: {e}").into(); + let error: SharedString = format!("Failed to unwrap: {e}").into(); tx.send_async(Signal::Error(error)).await?; } } @@ -325,6 +337,7 @@ impl ChatRegistry { while let Some((_url, res)) = stream.next().await { if let Ok(event) = res { + log::debug!("Got event: {:?}", event); let urls: Vec = nip17::extract_owned_relay_list(event).collect(); return Ok(urls); } @@ -399,6 +412,24 @@ impl ChatRegistry { .count() } + /// Count the number of messages seen by a given relay. + pub fn count_messages(&self, relay_url: &RelayUrl) -> usize { + self.seens + .read_blocking() + .values() + .filter(|seen| seen.contains(relay_url)) + .count() + } + + /// Get the relays that have seen a given message. + pub fn seen_on(&self, id: &EventId) -> HashSet { + self.seens + .read_blocking() + .get(id) + .cloned() + .unwrap_or_default() + } + /// Add a new room to the start of list. pub fn add_room(&mut self, room: I, cx: &mut Context) where diff --git a/crates/chat_ui/src/actions.rs b/crates/chat_ui/src/actions.rs index fdb5fbb..25ab534 100644 --- a/crates/chat_ui/src/actions.rs +++ b/crates/chat_ui/src/actions.rs @@ -10,7 +10,6 @@ pub enum Command { ChangeSubject(String), ChangeSigner(SignerKind), ToggleBackup, - Subject, Copy(PublicKey), Relays(PublicKey), Njump(PublicKey), diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 7377bdc..fde7ef2 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -154,6 +154,7 @@ impl ChatPanel { // Define all functions that will run after the current cycle cx.defer_in(window, |this, window, cx| { + this.connect(cx); this.handle_notifications(cx); this.subscribe_room_events(window, cx); this.get_messages(window, cx); @@ -179,6 +180,14 @@ impl ChatPanel { } } + /// Get messaging relays and announcement for each member + fn connect(&mut self, cx: &mut Context) { + if let Some(room) = self.room.upgrade() { + let task = room.read(cx).connect(cx); + self.tasks.push(task); + } + } + /// Handle nostr notifications fn handle_notifications(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); @@ -247,11 +256,13 @@ impl ChatPanel { })); } + /// Subscribe to room events fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context) { if let Some(room) = self.room.upgrade() { - self.subscriptions.push( - // Subscribe to room events - cx.subscribe_in(&room, window, move |this, _room, event, window, cx| { + self.subscriptions.push(cx.subscribe_in( + &room, + window, + move |this, _room, event, window, cx| { match event { RoomEvent::Incoming(message) => { this.insert_message(message, false, cx); @@ -260,8 +271,8 @@ impl ChatPanel { this.get_messages(window, cx); } }; - }), - ); + }, + )); } } @@ -645,9 +656,6 @@ impl ChatPanel { ); } } - Command::Subject => { - self.open_subject(window, cx); - } Command::Copy(public_key) => { self.copy_author(public_key, cx); } @@ -660,47 +668,6 @@ impl ChatPanel { } } - fn open_subject(&mut self, window: &mut Window, cx: &mut Context) { - let subject_input = self.subject_input.clone(); - - window.open_modal(cx, move |this, _window, cx| { - let subject = subject_input.read(cx).value(); - - this.title("Change subject") - .show_close(true) - .confirm() - .child( - v_flex() - .gap_2() - .child( - v_flex() - .gap_1p5() - .child( - div() - .text_sm() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Subject:")), - ) - .child(TextInput::new(&subject_input).small()), - ) - .child( - div() - .italic() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(SharedString::from( - "Subject will be updated when you send a new message.", - )), - ), - ) - .on_ok(move |_ev, window, cx| { - window - .dispatch_action(Box::new(Command::ChangeSubject(subject.to_string())), cx); - true - }) - }); - } - fn open_relays(&mut self, public_key: &PublicKey, window: &mut Window, cx: &mut Context) { let profile = self.profile(public_key, cx); diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs index 099bde9..4071dd9 100644 --- a/crates/coop/src/workspace.rs +++ b/crates/coop/src/workspace.rs @@ -27,9 +27,9 @@ use crate::dialogs::{accounts, settings}; use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list}; use crate::sidebar; +const PREPARE_MSG: &str = "Coop is preparing a new identity for you. This may take a moment..."; const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \ - Your identity is completely decoupled from all encryption processes to protect your privacy."; - + Your identity is completely decoupled from all encryption processes to protect your privacy."; const ENC_WARN: &str = "By resetting your encryption key, you will lose access to \ all your encrypted messages before. This action cannot be undone."; @@ -37,6 +37,7 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Workspace::new(window, cx)) } +struct SignerNotifcation; struct RelayNotifcation; #[derive(Action, Clone, PartialEq, Eq, Deserialize)] @@ -107,21 +108,37 @@ impl Workspace { // Subscribe to the signer events cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| { match event { + StateEvent::Creating => { + let note = Notification::new() + .id::() + .title("Preparing a new identity") + .message(PREPARE_MSG) + .autohide(false) + .with_kind(NotificationKind::Info); + + window.push_notification(note, cx); + } StateEvent::Connecting => { let note = Notification::new() .id::() - .message("Connecting to the bootstrap relay...") - .with_kind(NotificationKind::Info) - .icon(IconName::Relay); + .message("Connecting to the bootstrap relays...") + .with_kind(NotificationKind::Info); window.push_notification(note, cx); } StateEvent::Connected => { let note = Notification::new() .id::() - .message("Connected to the bootstrap relay") - .with_kind(NotificationKind::Success) - .icon(IconName::Relay); + .message("Connected to the bootstrap relays") + .with_kind(NotificationKind::Success); + + window.push_notification(note, cx); + } + StateEvent::FetchingRelayList => { + let note = Notification::new() + .id::() + .message("Getting relay list...") + .with_kind(NotificationKind::Info); window.push_notification(note, cx); } @@ -136,6 +153,8 @@ impl Workspace { this.set_center_layout(window, cx); this.set_relay_connected(false, cx); this.set_inbox_connected(false, cx); + // Clear the signer notification + window.clear_notification::(cx); } _ => {} }; @@ -728,28 +747,49 @@ impl Workspace { }) .when(inbox_connected, |this| this.indicator()) .dropdown_menu(move |this, _window, cx| { + let chat = ChatRegistry::global(cx); let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get(&public_key, cx); - let urls: Vec = profile + let urls: Vec<(SharedString, SharedString)> = profile .messaging_relays() .iter() - .map(|url| SharedString::from(url.to_string())) + .map(|url| { + ( + SharedString::from(url.to_string()), + chat.read(cx).count_messages(url).to_string().into(), + ) + }) .collect(); // Header let menu = this.min_w(px(260.)).label("Messaging Relays"); // Content - let menu = urls.into_iter().fold(menu, |this, url| { - this.item(PopupMenuItem::element(move |_window, _cx| { + let menu = urls.into_iter().fold(menu, |this, (url, count)| { + this.item(PopupMenuItem::element(move |_window, cx| { h_flex() .px_1() .w_full() - .gap_2() .text_sm() - .child(div().size_1p5().rounded_full().bg(gpui::green())) - .child(url.clone()) + .justify_between() + .child( + h_flex() + .gap_2() + .child( + div() + .size_1p5() + .rounded_full() + .bg(cx.theme().icon_accent), + ) + .child(url.clone()), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(count.clone()), + ) })) }); diff --git a/crates/device/src/lib.rs b/crates/device/src/lib.rs index 61dc1b3..9a6fe2d 100644 --- a/crates/device/src/lib.rs +++ b/crates/device/src/lib.rs @@ -6,11 +6,13 @@ use std::time::Duration; use anyhow::{Context as AnyhowContext, Error, anyhow}; use gpui::{ App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement, - SharedString, Styled, Task, Window, div, relative, + SharedString, Styled, Subscription, Task, Window, div, relative, }; use nostr_sdk::prelude::*; use person::PersonRegistry; -use state::{Announcement, DEVICE_GIFTWRAP, DeviceState, NostrRegistry, TIMEOUT, app_name}; +use state::{ + Announcement, DEVICE_GIFTWRAP, DeviceState, NostrRegistry, StateEvent, TIMEOUT, app_name, +}; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -48,6 +50,9 @@ pub struct DeviceRegistry { /// Async tasks tasks: Vec>>, + + /// Event subscription + _subscription: Option, } impl EventEmitter for DeviceRegistry {} @@ -65,16 +70,31 @@ impl DeviceRegistry { /// Create a new device registry instance fn new(window: &mut Window, cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); let state = DeviceState::default(); + let subscription = Some(cx.subscribe_in( + &nostr, + window, + |this, _state, event, _window, cx| match event { + StateEvent::SignerSet => { + this.reset(cx); + } + StateEvent::RelayConnected => { + this.get_announcement(cx); + } + _ => {} + }, + )); + cx.defer_in(window, |this, window, cx| { this.handle_notifications(window, cx); - this.get_announcement(cx); }); Self { state, tasks: vec![], + _subscription: subscription, } } @@ -254,9 +274,6 @@ impl DeviceRegistry { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - // Reset state before fetching announcement - self.reset(cx); - let task: Task> = cx.background_spawn(async move { let signer = client.signer().context("Signer not found")?; let public_key = signer.get_public_key().await?; @@ -358,7 +375,6 @@ impl DeviceRegistry { if keys.public_key() != device_pubkey { return Err(anyhow!("Key mismatch")); }; - Ok(keys) } else { Err(anyhow!("Key not found")) diff --git a/crates/relay_auth/src/lib.rs b/crates/relay_auth/src/lib.rs index b14e8e5..710d0d2 100644 --- a/crates/relay_auth/src/lib.rs +++ b/crates/relay_auth/src/lib.rs @@ -17,7 +17,7 @@ use state::NostrRegistry; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::notification::Notification; -use ui::{Disableable, IconName, Sizable, WindowExtension, v_flex}; +use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, v_flex}; const AUTH_MESSAGE: &str = "Approve the authentication request to allow Coop to continue sending or receiving events."; @@ -344,8 +344,9 @@ impl RelayAuth { .px_1p5() .rounded_sm() .text_xs() + .font_semibold() .bg(cx.theme().elevated_surface_background) - .text_color(cx.theme().text_accent) + .text_color(cx.theme().text) .child(url.clone()), ) .into_any_element() diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index e7f5d60..0a474e7 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -44,10 +44,14 @@ impl Global for GlobalNostrRegistry {} /// Signer event. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum StateEvent { + /// Creating the signer + Creating, /// Connecting to the bootstrapping relay Connecting, /// Connected to the bootstrapping relay Connected, + /// Fetching the relay list + FetchingRelayList, /// User has not set up NIP-65 relays RelayNotConfigured, /// Connected to NIP-65 relays @@ -58,6 +62,15 @@ pub enum StateEvent { Error(SharedString), } +impl StateEvent { + pub fn error(error: T) -> Self + where + T: Into, + { + Self::Error(error.into()) + } +} + /// Nostr Registry #[derive(Debug)] pub struct NostrRegistry { @@ -114,7 +127,7 @@ impl NostrRegistry { .gossip(gossip) .automatic_authentication(false) .verify_subscriptions(false) - .connect_timeout(Duration::from_secs(TIMEOUT)) + .connect_timeout(Duration::from_secs(10)) .sleep_when_idle(SleepWhenIdle::Enabled { timeout: Duration::from_secs(600), }); @@ -204,7 +217,7 @@ impl NostrRegistry { } Err(e) => { this.update(cx, |_this, cx| { - cx.emit(StateEvent::Error(SharedString::from(e.to_string()))); + cx.emit(StateEvent::error(e.to_string())); }) .ok(); } @@ -269,7 +282,7 @@ impl NostrRegistry { }, Err(e) => { this.update(cx, |_this, cx| { - cx.emit(StateEvent::Error(SharedString::from(e.to_string()))); + cx.emit(StateEvent::error(e.to_string())); }) .ok(); } @@ -289,6 +302,9 @@ impl NostrRegistry { // Create a write credential task let write_credential = cx.write_credentials(&username, &username, &secret); + // Emit creating event + cx.emit(StateEvent::Creating); + // Run async tasks in background let task: Task> = cx.background_spawn(async move { let signer = async_keys.into_nostr_signer(); @@ -301,7 +317,7 @@ impl NostrRegistry { client .send_event(&event) .to(BOOTSTRAP_RELAYS) - .ok_timeout(Duration::from_secs(TIMEOUT)) + .ack_policy(AckPolicy::none()) .await?; // Construct the default metadata @@ -355,7 +371,7 @@ impl NostrRegistry { } Err(e) => { this.update(cx, |_this, cx| { - cx.emit(StateEvent::Error(SharedString::from(e.to_string()))); + cx.emit(StateEvent::error(e.to_string())); }) .ok(); } @@ -453,7 +469,7 @@ impl NostrRegistry { } Err(e) => { this.update(cx, |_this, cx| { - cx.emit(StateEvent::Error(SharedString::from(e.to_string()))); + cx.emit(StateEvent::error(e.to_string())); }) .ok(); } @@ -500,7 +516,7 @@ impl NostrRegistry { } Err(e) => { this.update(cx, |_this, cx| { - cx.emit(StateEvent::Error(SharedString::from(e.to_string()))); + cx.emit(StateEvent::error(e.to_string())); }) .ok(); } @@ -545,7 +561,7 @@ impl NostrRegistry { } Err(e) => { this.update(cx, |_this, cx| { - cx.emit(StateEvent::Error(SharedString::from(e.to_string()))); + cx.emit(StateEvent::error(e.to_string())); }) .ok(); } @@ -553,7 +569,7 @@ impl NostrRegistry { } Err(e) => { this.update(cx, |_this, cx| { - cx.emit(StateEvent::Error(SharedString::from(e.to_string()))); + cx.emit(StateEvent::error(e.to_string())); }) .ok(); } @@ -561,9 +577,72 @@ impl NostrRegistry { })); } + /// Ensure the relay list is fetched for the given public key pub fn ensure_relay_list(&mut self, public_key: &PublicKey, cx: &mut Context) { let task = self.get_event(public_key, Kind::RelayList, cx); + // Emit a fetching event before starting the task + cx.emit(StateEvent::FetchingRelayList); + + self.tasks.push(cx.spawn(async move |this, cx| { + match task.await { + Ok(event) => { + this.update(cx, |this, cx| { + this.ensure_connection(&event, cx); + }) + .ok(); + } + Err(e) => { + this.update(cx, |_this, cx| { + cx.emit(StateEvent::RelayNotConfigured); + cx.emit(StateEvent::error(e.to_string())); + }) + .ok(); + } + }; + })); + } + + /// Ensure that the user is connected to the relay specified in the NIP-65 event. + pub fn ensure_connection(&mut self, event: &Event, cx: &mut Context) { + let client = self.client(); + // Extract the relay list from the event + let relays: Vec<(RelayUrl, Option)> = nip65::extract_relay_list(event) + .map(|(url, metadata)| (url.to_owned(), metadata.to_owned())) + .collect(); + + let task: Task> = cx.background_spawn(async move { + for (url, metadata) in relays.into_iter() { + match metadata { + Some(RelayMetadata::Read) => { + client + .add_relay(url) + .capabilities(RelayCapabilities::READ) + .connect_timeout(Duration::from_secs(TIMEOUT)) + .and_connect() + .await?; + } + Some(RelayMetadata::Write) => { + client + .add_relay(url) + .capabilities(RelayCapabilities::WRITE) + .connect_timeout(Duration::from_secs(TIMEOUT)) + .and_connect() + .await?; + } + None => { + client + .add_relay(url) + .capabilities(RelayCapabilities::NONE) + .connect_timeout(Duration::from_secs(TIMEOUT)) + .and_connect() + .await?; + } + } + } + Ok(()) + }); + self.tasks.push(cx.spawn(async move |this, cx| { match task.await { Ok(_) => { @@ -575,7 +654,7 @@ impl NostrRegistry { Err(e) => { this.update(cx, |_this, cx| { cx.emit(StateEvent::RelayNotConfigured); - cx.emit(StateEvent::Error(SharedString::from(e.to_string()))); + cx.emit(StateEvent::error(e.to_string())); }) .ok(); } diff --git a/crates/ui/src/notification.rs b/crates/ui/src/notification.rs index aa88753..b280e1a 100644 --- a/crates/ui/src/notification.rs +++ b/crates/ui/src/notification.rs @@ -14,7 +14,7 @@ use theme::{ActiveTheme, Anchor}; use crate::animation::cubic_bezier; use crate::button::{Button, ButtonVariants as _}; -use crate::{Icon, IconName, Sizable as _, StyledExt, h_flex, v_flex}; +use crate::{Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex}; #[derive(Debug, Clone, Copy, Default)] pub enum NotificationKind { @@ -28,12 +28,18 @@ pub enum NotificationKind { impl NotificationKind { fn icon(&self, cx: &App) -> Icon { match self { - Self::Info => Icon::new(IconName::Info).text_color(cx.theme().icon), - Self::Success => Icon::new(IconName::CheckCircle).text_color(cx.theme().icon_accent), - Self::Warning => Icon::new(IconName::Warning).text_color(cx.theme().text_warning), - Self::Error => { - Icon::new(IconName::CloseCircle).text_color(cx.theme().danger_foreground) - } + Self::Info => Icon::new(IconName::Info) + .with_size(Size::Medium) + .text_color(cx.theme().icon), + Self::Success => Icon::new(IconName::CheckCircle) + .with_size(Size::Medium) + .text_color(cx.theme().icon_accent), + Self::Warning => Icon::new(IconName::Warning) + .with_size(Size::Medium) + .text_color(cx.theme().text_warning), + Self::Error => Icon::new(IconName::CloseCircle) + .with_size(Size::Medium) + .text_color(cx.theme().danger_foreground), } } } @@ -284,9 +290,6 @@ impl Styled for Notification { } impl Render for Notification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let closing = self.closing; - let placement = cx.theme().notification.placement; - let content = self .content_builder .clone() @@ -312,6 +315,11 @@ impl Render for Notification { _ => cx.theme().text, }; + let closing = self.closing; + let has_title = self.title.is_some(); + let only_message = !has_title && content.is_none() && action.is_none(); + let placement = cx.theme().notification.placement; + h_flex() .id("notification") .group("") @@ -328,23 +336,38 @@ impl Render for Notification { .gap_2() .justify_start() .items_start() + .when(only_message, |this| this.items_center()) .refine_style(&self.style) .when_some(icon, |this, icon| { - this.child(div().flex_shrink_0().child(icon)) + this.child(div().flex_shrink_0().size_5().child(icon)) }) .child( v_flex() .flex_1() + .gap_1() .overflow_hidden() .when_some(self.title.clone(), |this, title| { - this.child(div().text_sm().font_semibold().child(title)) + this.child(h_flex().h_5().text_sm().font_semibold().child(title)) }) .when_some(self.message.clone(), |this, message| { - this.child(div().text_sm().line_height(relative(1.25)).child(message)) + this.child( + div() + .text_sm() + .when(has_title, |this| this.text_color(cx.theme().text_muted)) + .line_height(relative(1.3)) + .child(message), + ) }) .when_some(content, |this, content| this.child(content)) .when_some(action, |this, action| { - this.child(h_flex().flex_1().gap_1().justify_end().child(action)) + this.child( + h_flex() + .w_full() + .flex_1() + .gap_1() + .justify_end() + .child(action), + ) }), ) .child(