Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m48s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m37s
722 lines
26 KiB
Rust
722 lines
26 KiB
Rust
use std::ops::Range;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{anyhow, Error};
|
|
use chat::{ChatEvent, ChatRegistry, Room};
|
|
use common::{DebouncedDelay, RenderedTimestamp, TextUtils, BOOTSTRAP_RELAYS, SEARCH_RELAYS};
|
|
use dock::panel::{Panel, PanelEvent};
|
|
use gpui::prelude::FluentBuilder;
|
|
use gpui::{
|
|
deferred, div, rems, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
|
Focusable, InteractiveElement, IntoElement, ParentElement, Render, RetainAllImageCache,
|
|
SharedString, Styled, Subscription, Task, Window,
|
|
};
|
|
use gpui_tokio::Tokio;
|
|
use list_item::RoomListItem;
|
|
use nostr_sdk::prelude::*;
|
|
use person::PersonRegistry;
|
|
use smallvec::{smallvec, SmallVec};
|
|
use state::{NostrRegistry, GIFTWRAP_SUBSCRIPTION};
|
|
use theme::{ActiveTheme, TITLEBAR_HEIGHT};
|
|
use ui::avatar::Avatar;
|
|
use ui::button::{Button, ButtonVariants};
|
|
use ui::indicator::Indicator;
|
|
use ui::input::{InputEvent, InputState, TextInput};
|
|
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt, WindowExtension};
|
|
|
|
use crate::actions::{RelayStatus, Reload};
|
|
use crate::dialogs::compose::compose_button;
|
|
|
|
mod list_item;
|
|
|
|
const FIND_DELAY: u64 = 600;
|
|
const FIND_LIMIT: usize = 20;
|
|
|
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
|
|
cx.new(|cx| Sidebar::new(window, cx))
|
|
}
|
|
|
|
/// Sidebar.
|
|
pub struct Sidebar {
|
|
name: SharedString,
|
|
|
|
/// Focus handle for the sidebar
|
|
focus_handle: FocusHandle,
|
|
|
|
/// Image cache
|
|
image_cache: Entity<RetainAllImageCache>,
|
|
|
|
/// Search results
|
|
search_results: Entity<Option<Vec<Entity<Room>>>>,
|
|
|
|
/// Async search operation
|
|
search_task: Option<Task<()>>,
|
|
|
|
/// Search input state
|
|
find_input: Entity<InputState>,
|
|
|
|
/// Debounced delay for search input
|
|
find_debouncer: DebouncedDelay<Self>,
|
|
|
|
/// Whether searching is in progress
|
|
finding: bool,
|
|
|
|
/// New request flag
|
|
new_request: bool,
|
|
|
|
/// Event subscriptions
|
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
|
}
|
|
|
|
impl Sidebar {
|
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
let search_results = cx.new(|_| None);
|
|
|
|
// Define the find input state
|
|
let find_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder("Find or start a conversation")
|
|
.clean_on_escape()
|
|
});
|
|
|
|
// Get the chat registry
|
|
let chat = ChatRegistry::global(cx);
|
|
|
|
let mut subscriptions = smallvec![];
|
|
|
|
subscriptions.push(
|
|
// Subscribe for registry new events
|
|
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
|
|
if event == &ChatEvent::Ping {
|
|
this.new_request = true;
|
|
cx.notify();
|
|
};
|
|
}),
|
|
);
|
|
|
|
subscriptions.push(
|
|
// Subscribe for find input events
|
|
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
|
|
let delay = Duration::from_millis(FIND_DELAY);
|
|
|
|
match event {
|
|
InputEvent::PressEnter { .. } => {
|
|
this.search(window, cx);
|
|
}
|
|
InputEvent::Change => {
|
|
if state.read(cx).value().is_empty() {
|
|
// Clear the result when input is empty
|
|
this.clear(window, cx);
|
|
} else {
|
|
// Run debounced search
|
|
this.find_debouncer
|
|
.fire_new(delay, window, cx, |this, window, cx| {
|
|
this.debounced_search(window, cx)
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
};
|
|
}),
|
|
);
|
|
|
|
Self {
|
|
name: "Sidebar".into(),
|
|
focus_handle: cx.focus_handle(),
|
|
image_cache: RetainAllImageCache::new(cx),
|
|
find_debouncer: DebouncedDelay::new(),
|
|
finding: false,
|
|
new_request: false,
|
|
find_input,
|
|
search_results,
|
|
search_task: None,
|
|
_subscriptions: subscriptions,
|
|
}
|
|
}
|
|
|
|
async fn nip50(client: &Client, query: &str) -> Result<Vec<Event>, Error> {
|
|
let signer = client.signer().await?;
|
|
let public_key = signer.get_public_key().await?;
|
|
|
|
let filter = Filter::new()
|
|
.kind(Kind::Metadata)
|
|
.search(query.to_lowercase())
|
|
.limit(FIND_LIMIT);
|
|
|
|
let mut stream = client
|
|
.stream_events_from(SEARCH_RELAYS, filter, Duration::from_secs(3))
|
|
.await?;
|
|
|
|
let mut results: Vec<Event> = Vec::with_capacity(FIND_LIMIT);
|
|
|
|
while let Some((_url, event)) = stream.next().await {
|
|
if let Ok(event) = event {
|
|
// Skip if author is match current user
|
|
if event.pubkey == public_key {
|
|
continue;
|
|
}
|
|
|
|
// Skip if the event has already been added
|
|
if results.iter().any(|this| this.pubkey == event.pubkey) {
|
|
continue;
|
|
}
|
|
|
|
results.push(event);
|
|
}
|
|
}
|
|
|
|
if results.is_empty() {
|
|
return Err(anyhow!("No results for query {query}"));
|
|
}
|
|
|
|
// Get all public keys
|
|
let public_keys: Vec<PublicKey> = results.iter().map(|event| event.pubkey).collect();
|
|
|
|
// Fetch metadata and contact lists if public keys is not empty
|
|
if !public_keys.is_empty() {
|
|
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
|
let filter = Filter::new()
|
|
.kinds(vec![Kind::Metadata, Kind::ContactList])
|
|
.limit(public_keys.len() * 2)
|
|
.authors(public_keys);
|
|
|
|
client
|
|
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
|
.await?;
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
this.update_in(cx, |this, window, cx| {
|
|
this.search(window, cx);
|
|
})
|
|
.ok();
|
|
})
|
|
}
|
|
|
|
fn search_by_nip50(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
|
|
let nostr = NostrRegistry::global(cx);
|
|
let client = nostr.read(cx).client();
|
|
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
|
|
|
let query = query.to_owned();
|
|
|
|
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
|
|
let result = Self::nip50(&client, &query).await;
|
|
|
|
this.update_in(cx, |this, window, cx| {
|
|
match result {
|
|
Ok(results) => {
|
|
let rooms = results
|
|
.into_iter()
|
|
.map(|event| {
|
|
cx.new(|_| Room::new(None, public_key, vec![event.pubkey]))
|
|
})
|
|
.collect();
|
|
|
|
this.set_results(rooms, cx);
|
|
}
|
|
Err(e) => {
|
|
window.push_notification(e.to_string(), cx);
|
|
}
|
|
};
|
|
this.set_finding(false, window, cx);
|
|
})
|
|
.ok();
|
|
}));
|
|
}
|
|
|
|
fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
|
|
let nostr = NostrRegistry::global(cx);
|
|
let client = nostr.read(cx).client();
|
|
let address = query.to_owned();
|
|
|
|
let task = Tokio::spawn(cx, async move {
|
|
match common::nip05_profile(&address).await {
|
|
Ok(profile) => {
|
|
let signer = client.signer().await?;
|
|
let public_key = signer.get_public_key().await?;
|
|
let receivers = vec![profile.public_key];
|
|
let room = Room::new(None, public_key, receivers);
|
|
|
|
Ok(room)
|
|
}
|
|
Err(e) => Err(anyhow!(e)),
|
|
}
|
|
});
|
|
|
|
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
|
|
let result = task.await;
|
|
|
|
this.update_in(cx, |this, window, cx| {
|
|
match result {
|
|
Ok(Ok(room)) => {
|
|
this.set_results(vec![cx.new(|_| room)], cx);
|
|
}
|
|
Ok(Err(e)) => {
|
|
window.push_notification(e.to_string(), cx);
|
|
}
|
|
Err(e) => {
|
|
window.push_notification(e.to_string(), cx);
|
|
}
|
|
}
|
|
this.set_finding(false, window, cx);
|
|
})
|
|
.ok();
|
|
}));
|
|
}
|
|
|
|
fn search_by_pubkey(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
|
|
let nostr = NostrRegistry::global(cx);
|
|
let client = nostr.read(cx).client();
|
|
|
|
let Ok(public_key) = query.to_public_key() else {
|
|
window.push_notification("Public Key is invalid", cx);
|
|
self.set_finding(false, window, cx);
|
|
return;
|
|
};
|
|
|
|
let task: Task<Result<Room, Error>> = cx.background_spawn(async move {
|
|
let signer = client.signer().await?;
|
|
let author = signer.get_public_key().await?;
|
|
|
|
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
|
let receivers = vec![public_key];
|
|
let room = Room::new(None, author, receivers);
|
|
|
|
let filter = Filter::new()
|
|
.kinds(vec![Kind::Metadata, Kind::ContactList])
|
|
.author(public_key)
|
|
.limit(2);
|
|
|
|
client
|
|
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
|
.await?;
|
|
|
|
Ok(room)
|
|
});
|
|
|
|
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
|
|
let result = task.await;
|
|
|
|
this.update_in(cx, |this, window, cx| {
|
|
match result {
|
|
Ok(room) => {
|
|
let chat = ChatRegistry::global(cx);
|
|
let local_results = chat.read(cx).search_by_public_key(public_key, cx);
|
|
|
|
if !local_results.is_empty() {
|
|
this.set_results(local_results, cx);
|
|
} else {
|
|
this.set_results(vec![cx.new(|_| room)], cx);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
window.push_notification(e.to_string(), cx);
|
|
}
|
|
};
|
|
this.set_finding(false, window, cx);
|
|
})
|
|
.ok();
|
|
}));
|
|
}
|
|
|
|
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
// Return if the query is empty
|
|
if self.find_input.read(cx).value().is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Return if search is in progress
|
|
if self.finding {
|
|
if self.search_task.is_none() {
|
|
window.push_notification("There is another search in progress", cx);
|
|
return;
|
|
} else {
|
|
// Cancel ongoing search request
|
|
self.search_task = None;
|
|
}
|
|
}
|
|
|
|
let input = self.find_input.read(cx).value();
|
|
let query = input.to_string();
|
|
|
|
// Block the input until the search process completes
|
|
self.set_finding(true, window, cx);
|
|
|
|
// Process to search by pubkey if query starts with npub or nprofile
|
|
if query.starts_with("npub1") || query.starts_with("nprofile1") {
|
|
self.search_by_pubkey(&query, window, cx);
|
|
return;
|
|
};
|
|
|
|
// Process to search by NIP05 if query is a valid NIP-05 identifier (name@domain.tld)
|
|
if query.split('@').count() == 2 {
|
|
let parts: Vec<&str> = query.split('@').collect();
|
|
if !parts[0].is_empty() && !parts[1].is_empty() && parts[1].contains('.') {
|
|
self.search_by_nip05(&query, window, cx);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Get all local results with current query
|
|
let chat = ChatRegistry::global(cx);
|
|
let local_results = chat.read(cx).search(&query, cx);
|
|
|
|
// Try to update with local results first
|
|
if !local_results.is_empty() {
|
|
self.set_results(local_results, cx);
|
|
return;
|
|
};
|
|
|
|
// If no local results, try global search via NIP-50
|
|
self.search_by_nip50(&query, window, cx);
|
|
}
|
|
|
|
fn set_results(&mut self, rooms: Vec<Entity<Room>>, cx: &mut Context<Self>) {
|
|
self.search_results.update(cx, |this, cx| {
|
|
*this = Some(rooms);
|
|
cx.notify();
|
|
});
|
|
}
|
|
|
|
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
|
|
// Disable the input to prevent duplicate requests
|
|
self.find_input.update(cx, |this, cx| {
|
|
this.set_disabled(status, cx);
|
|
this.set_loading(status, cx);
|
|
});
|
|
// Set the finding status
|
|
self.finding = status;
|
|
cx.notify();
|
|
}
|
|
|
|
fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
// Reset the input state
|
|
if self.finding {
|
|
self.set_finding(false, window, cx);
|
|
}
|
|
|
|
// Clear all local results
|
|
self.search_results.update(cx, |this, cx| {
|
|
*this = None;
|
|
cx.notify();
|
|
});
|
|
}
|
|
|
|
fn open(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
|
let chat = ChatRegistry::global(cx);
|
|
|
|
match chat.read(cx).room(&id, cx) {
|
|
Some(room) => {
|
|
chat.update(cx, |this, cx| {
|
|
this.emit_room(room, cx);
|
|
});
|
|
}
|
|
None => {
|
|
if let Some(room) = self
|
|
.search_results
|
|
.read(cx)
|
|
.as_ref()
|
|
.and_then(|results| results.iter().find(|this| this.read(cx).id == id))
|
|
.map(|this| this.downgrade())
|
|
{
|
|
chat.update(cx, |this, cx| {
|
|
this.emit_room(room, cx);
|
|
});
|
|
// Clear all search results
|
|
self.clear(window, cx);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_reload(&mut self, _ev: &Reload, window: &mut Window, cx: &mut Context<Self>) {
|
|
ChatRegistry::global(cx).update(cx, |this, cx| {
|
|
this.get_rooms(cx);
|
|
});
|
|
window.push_notification("Reload", cx);
|
|
}
|
|
|
|
fn on_manage(&mut self, _ev: &RelayStatus, window: &mut Window, cx: &mut Context<Self>) {
|
|
let nostr = NostrRegistry::global(cx);
|
|
let client = nostr.read(cx).client();
|
|
|
|
let task: Task<Result<Vec<Relay>, Error>> = cx.background_spawn(async move {
|
|
let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
|
|
let subscription = client.subscription(&id).await;
|
|
|
|
let mut relays: Vec<Relay> = vec![];
|
|
|
|
for (url, _filter) in subscription.into_iter() {
|
|
relays.push(client.pool().relay(url).await?);
|
|
}
|
|
|
|
Ok(relays)
|
|
});
|
|
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
if let Ok(relays) = task.await {
|
|
this.update_in(cx, |this, window, cx| {
|
|
this.manage_relays(relays, window, cx);
|
|
})
|
|
.ok();
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn manage_relays(&mut self, relays: Vec<Relay>, window: &mut Window, cx: &mut Context<Self>) {
|
|
window.open_modal(cx, move |this, _window, cx| {
|
|
this.show_close(true)
|
|
.overlay_closable(true)
|
|
.keyboard(true)
|
|
.title(SharedString::from("Messaging Relay Status"))
|
|
.child(v_flex().pb_4().gap_2().children({
|
|
let mut items = Vec::with_capacity(relays.len());
|
|
|
|
for relay in relays.clone().into_iter() {
|
|
let url = relay.url().to_string();
|
|
let time = relay.stats().connected_at().to_ago();
|
|
let connected = relay.is_connected();
|
|
|
|
items.push(
|
|
h_flex()
|
|
.h_8()
|
|
.px_2()
|
|
.justify_between()
|
|
.text_xs()
|
|
.bg(cx.theme().elevated_surface_background)
|
|
.rounded(cx.theme().radius)
|
|
.child(
|
|
h_flex()
|
|
.gap_1()
|
|
.font_semibold()
|
|
.child(
|
|
Icon::new(IconName::Relay)
|
|
.small()
|
|
.text_color(cx.theme().danger_active)
|
|
.when(connected, |this| {
|
|
this.text_color(gpui::green().alpha(0.75))
|
|
}),
|
|
)
|
|
.child(url),
|
|
)
|
|
.child(
|
|
div().text_right().text_color(cx.theme().text_muted).child(
|
|
SharedString::from(format!("Last activity: {}", time)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
items
|
|
}))
|
|
});
|
|
}
|
|
|
|
fn render_list_items(
|
|
&self,
|
|
range: Range<usize>,
|
|
_window: &Window,
|
|
cx: &Context<Self>,
|
|
) -> Vec<impl IntoElement> {
|
|
// Get rooms from search results first
|
|
let rooms = match self.search_results.read(cx).as_ref() {
|
|
Some(results) => results,
|
|
None => {
|
|
// Fallback to chat registry if there are no search results
|
|
let chat = ChatRegistry::global(cx);
|
|
chat.read(cx).rooms(cx)
|
|
}
|
|
};
|
|
|
|
rooms
|
|
.get(range.clone())
|
|
.into_iter()
|
|
.flatten()
|
|
.enumerate()
|
|
.map(|(ix, item)| {
|
|
let room = item.read(cx);
|
|
let id = room.id;
|
|
let public_key = room.display_member(cx).public_key();
|
|
|
|
let handler = cx.listener({
|
|
move |this, _ev, window, cx| {
|
|
this.open(id, window, cx);
|
|
}
|
|
});
|
|
|
|
RoomListItem::new(range.start + ix)
|
|
.name(room.display_name(cx))
|
|
.avatar(room.display_image(cx))
|
|
.public_key(public_key)
|
|
.kind(room.kind)
|
|
.created_at(room.created_at.to_ago())
|
|
.on_click(handler)
|
|
.into_any_element()
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
impl Panel for Sidebar {
|
|
fn panel_id(&self) -> SharedString {
|
|
self.name.clone()
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<PanelEvent> for Sidebar {}
|
|
|
|
impl Focusable for Sidebar {
|
|
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
self.focus_handle.clone()
|
|
}
|
|
}
|
|
|
|
impl Render for Sidebar {
|
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let nostr = NostrRegistry::global(cx);
|
|
let identity = nostr.read(cx).identity();
|
|
|
|
let chat = ChatRegistry::global(cx);
|
|
let loading = chat.read(cx).loading();
|
|
|
|
// Get total rooms
|
|
let total_rooms = match self.search_results.read(cx).as_ref() {
|
|
Some(results) => results.len(),
|
|
None => chat.read(cx).rooms(cx).len(),
|
|
};
|
|
|
|
v_flex()
|
|
.on_action(cx.listener(Self::on_reload))
|
|
.on_action(cx.listener(Self::on_manage))
|
|
.image_cache(self.image_cache.clone())
|
|
.size_full()
|
|
.relative()
|
|
.gap_2()
|
|
.bg(cx.theme().surface_background)
|
|
// Titlebar
|
|
.child(
|
|
h_flex()
|
|
.px_2p5()
|
|
.child(
|
|
h_flex()
|
|
.w_full()
|
|
.gap_2()
|
|
.justify_between()
|
|
.when_some(identity.read(cx).public_key, |this, public_key| {
|
|
let persons = PersonRegistry::global(cx);
|
|
let profile = persons.read(cx).get(&public_key, cx);
|
|
|
|
this.child(
|
|
h_flex()
|
|
.gap_1p5()
|
|
.text_xs()
|
|
.text_color(cx.theme().text_muted)
|
|
.child(Avatar::new(profile.avatar()).size(rems(1.25)))
|
|
.child(profile.name()),
|
|
)
|
|
})
|
|
.child(compose_button()),
|
|
)
|
|
.h(TITLEBAR_HEIGHT),
|
|
)
|
|
// Search Input
|
|
.child(
|
|
div().px_2p5().child(
|
|
TextInput::new(&self.find_input)
|
|
.small()
|
|
.cleanable()
|
|
.appearance(true)
|
|
.text_xs()
|
|
.flex_none()
|
|
.map(|this| {
|
|
if !self.find_input.read(cx).loading {
|
|
this.suffix(
|
|
Button::new("find")
|
|
.icon(IconName::Search)
|
|
.tooltip("Press Enter to search")
|
|
.transparent()
|
|
.small(),
|
|
)
|
|
} else {
|
|
this
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
.when(!loading && total_rooms == 0, |this| {
|
|
this.child(
|
|
div().px_2p5().child(deferred(
|
|
v_flex()
|
|
.p_3()
|
|
.h_24()
|
|
.w_full()
|
|
.border_2()
|
|
.border_dashed()
|
|
.border_color(cx.theme().border_variant)
|
|
.rounded(cx.theme().radius_lg)
|
|
.items_center()
|
|
.justify_center()
|
|
.text_center()
|
|
.child(
|
|
div()
|
|
.text_sm()
|
|
.font_semibold()
|
|
.child(SharedString::from("No conversations")),
|
|
)
|
|
.child(div().text_xs().text_color(cx.theme().text_muted).child(
|
|
SharedString::from(
|
|
"Start a conversation with someone to get started.",
|
|
),
|
|
)),
|
|
)),
|
|
)
|
|
})
|
|
// Chat Rooms
|
|
.child(
|
|
v_flex()
|
|
.px_1p5()
|
|
.w_full()
|
|
.flex_1()
|
|
.gap_1()
|
|
.overflow_y_hidden()
|
|
.child(
|
|
uniform_list(
|
|
"rooms",
|
|
total_rooms,
|
|
cx.processor(|this, range, window, cx| {
|
|
this.render_list_items(range, window, cx)
|
|
}),
|
|
)
|
|
.h_full(),
|
|
)
|
|
.when(loading, |this| {
|
|
this.child(
|
|
div().absolute().top_2().left_0().w_full().px_8().child(
|
|
h_flex()
|
|
.gap_2()
|
|
.w_full()
|
|
.h_9()
|
|
.justify_center()
|
|
.bg(cx.theme().background.opacity(0.85))
|
|
.border_color(cx.theme().border_disabled)
|
|
.border_1()
|
|
.when(cx.theme().shadow, |this| this.shadow_sm())
|
|
.rounded_full()
|
|
.text_xs()
|
|
.font_semibold()
|
|
.text_color(cx.theme().text_muted)
|
|
.child(Indicator::new().small().color(cx.theme().icon_accent))
|
|
.child(SharedString::from("Getting messages...")),
|
|
),
|
|
)
|
|
}),
|
|
)
|
|
}
|
|
}
|