feat: manually handle NIP-42 auth request (#132)

* improve fetch relays

* .

* .

* .

* refactor

* refactor

* remove identity

* manually auth

* auth

* prevent duplicate message

* clean up
This commit is contained in:
reya
2025-08-30 14:38:00 +07:00
committed by GitHub
parent 49a3dedd9c
commit 807851518a
33 changed files with 1810 additions and 1443 deletions

View File

@@ -30,7 +30,6 @@ icons = [
assets = { path = "../assets" }
ui = { path = "../ui" }
title_bar = { path = "../title_bar" }
identity = { path = "../identity" }
theme = { path = "../theme" }
common = { path = "../common" }
global = { path = "../global" }
@@ -38,6 +37,7 @@ registry = { path = "../registry" }
settings = { path = "../settings" }
client_keys = { path = "../client_keys" }
auto_update = { path = "../auto_update" }
signer_proxy = { path = "../signer_proxy" }
rust-i18n.workspace = true
i18n.workspace = true
@@ -59,5 +59,6 @@ smallvec.workspace = true
smol.workspace = true
futures.workspace = true
oneshot.workspace = true
webbrowser.workspace = true
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }

View File

@@ -0,0 +1,34 @@
use std::sync::Mutex;
use gpui::{actions, App};
actions!(coop, [DarkMode, Settings, Logout, Quit]);
pub fn load_embedded_fonts(cx: &App) {
let asset_source = cx.asset_source();
let font_paths = asset_source.list("fonts").unwrap();
let embedded_fonts = Mutex::new(Vec::new());
let executor = cx.background_executor();
executor.block(executor.scoped(|scope| {
for font_path in &font_paths {
if !font_path.ends_with(".ttf") {
continue;
}
scope.spawn(async {
let font_bytes = asset_source.load(font_path).unwrap().unwrap();
embedded_fonts.lock().unwrap().push(font_bytes);
});
}
}));
cx.text_system()
.add_fonts(embedded_fonts.into_inner().unwrap())
.unwrap();
}
pub fn quit(_: &Quit, cx: &mut App) {
log::info!("Gracefully quitting the application . . .");
cx.quit();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,193 +1,45 @@
use std::collections::BTreeSet;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::sync::Arc;
use anyhow::{anyhow, Error};
use assets::Assets;
use common::event::EventUtils;
use global::constants::{
APP_ID, APP_NAME, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT, METADATA_BATCH_TIMEOUT,
SEARCH_RELAYS, WAIT_FOR_FINISH,
};
use global::{global_channel, nostr_client, processed_events, starting_time, NostrSignal};
use global::constants::{APP_ID, APP_NAME};
use global::{ingester, nostr_client, sent_ids, starting_time};
use gpui::{
actions, point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
SharedString, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations,
WindowKind, WindowOptions,
point, px, size, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SharedString,
TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind,
WindowOptions,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use smol::channel::{self, Sender};
use theme::Theme;
use ui::Root;
use crate::actions::{load_embedded_fonts, quit, Quit};
pub(crate) mod actions;
pub(crate) mod chatspace;
pub(crate) mod views;
i18n::init!();
actions!(coop, [Quit]);
fn main() {
// Initialize logging
tracing_subscriber::fmt::init();
// Initialize the Nostr Client
let client = nostr_client();
// Initialize the Nostr client
let _client = nostr_client();
// Initialize the ingester
let _ingester = ingester();
// Initialize the starting time
let _starting_time = starting_time();
// Initialize the sent IDs storage
let _sent_ids = sent_ids();
// Initialize the Application
let app = Application::new()
.with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
let (pubkey_tx, pubkey_rx) = channel::bounded::<PublicKey>(1024);
let (event_tx, event_rx) = channel::bounded::<Event>(2048);
app.background_executor()
.spawn(async move {
// Subscribe for app updates from the bootstrap relays.
if let Err(e) = connect(client).await {
log::error!("Failed to connect to bootstrap relays: {e}");
}
// Handle Nostr notifications.
//
// Send the redefined signal back to GPUI via channel.
if let Err(e) = handle_nostr_notifications(&event_tx).await {
log::error!("Failed to handle Nostr notifications: {e}");
}
})
.detach();
app.background_executor()
.spawn(async move {
let channel = global_channel();
loop {
if let Ok(signer) = client.signer().await {
if let Ok(public_key) = signer.get_public_key().await {
// Notify the app that the signer has been set.
_ = channel.0.send(NostrSignal::SignerSet(public_key)).await;
// Get the NIP-65 relays for the public key.
get_nip65_relays(public_key).await.ok();
break;
}
}
smol::Timer::after(Duration::from_secs(1)).await;
}
})
.detach();
app.background_executor()
.spawn(async move {
let duration = Duration::from_millis(METADATA_BATCH_TIMEOUT);
let mut processed_pubkeys: BTreeSet<PublicKey> = BTreeSet::new();
let mut batch: BTreeSet<PublicKey> = BTreeSet::new();
/// Internal events for the metadata batching system
enum BatchEvent {
NewKeys(PublicKey),
Timeout,
Closed,
}
loop {
let duration = smol::Timer::after(duration);
let recv = || async {
if let Ok(public_key) = pubkey_rx.recv().await {
BatchEvent::NewKeys(public_key)
} else {
BatchEvent::Closed
}
};
let timeout = || async {
duration.await;
BatchEvent::Timeout
};
match smol::future::or(recv(), timeout()).await {
BatchEvent::NewKeys(public_key) => {
// Prevent duplicate keys from being processed
if processed_pubkeys.insert(public_key) {
batch.insert(public_key);
}
// Process the batch if it's full
if batch.len() >= METADATA_BATCH_LIMIT {
sync_data_for_pubkeys(std::mem::take(&mut batch)).await;
}
}
BatchEvent::Timeout => {
if !batch.is_empty() {
sync_data_for_pubkeys(std::mem::take(&mut batch)).await;
}
}
BatchEvent::Closed => {
if !batch.is_empty() {
sync_data_for_pubkeys(std::mem::take(&mut batch)).await;
}
break;
}
}
}
})
.detach();
app.background_executor()
.spawn(async move {
let channel = global_channel();
let mut counter = 0;
loop {
// Signer is unset, probably user is not ready to retrieve gift wrap events
if client.signer().await.is_err() {
smol::Timer::after(Duration::from_secs(1)).await;
continue;
}
let duration = smol::Timer::after(Duration::from_secs(WAIT_FOR_FINISH));
let recv = || async {
// no inline
(event_rx.recv().await).ok()
};
let timeout = || async {
duration.await;
None
};
match smol::future::or(recv(), timeout()).await {
Some(event) => {
let cached = unwrap_gift(&event, &pubkey_tx).await;
// Increment the total messages counter if message is not from cache
if !cached {
counter += 1;
}
// Send partial finish signal to GPUI
if counter >= 20 {
channel.0.send(NostrSignal::PartialFinish).await.ok();
// Reset counter
counter = 0;
}
}
None => {
// Notify the UI that the processing is finished
channel.0.send(NostrSignal::Finish).await.ok();
}
}
}
})
.detach();
// Run application
app.run(move |cx| {
// Load embedded fonts in assets/fonts
@@ -264,280 +116,3 @@ fn main() {
.expect("Failed to open window. Please restart the application.");
});
}
fn load_embedded_fonts(cx: &App) {
let asset_source = cx.asset_source();
let font_paths = asset_source.list("fonts").unwrap();
let embedded_fonts = Mutex::new(Vec::new());
let executor = cx.background_executor();
executor.block(executor.scoped(|scope| {
for font_path in &font_paths {
if !font_path.ends_with(".ttf") {
continue;
}
scope.spawn(async {
let font_bytes = asset_source.load(font_path).unwrap().unwrap();
embedded_fonts.lock().unwrap().push(font_bytes);
});
}
}));
cx.text_system()
.add_fonts(embedded_fonts.into_inner().unwrap())
.unwrap();
}
fn quit(_: &Quit, cx: &mut App) {
log::info!("Gracefully quitting the application . . .");
cx.quit();
}
async fn connect(client: &Client) -> Result<(), Error> {
for relay in BOOTSTRAP_RELAYS.into_iter() {
client.add_relay(relay).await?;
}
log::info!("Connected to bootstrap relays");
for relay in SEARCH_RELAYS.into_iter() {
client.add_relay(relay).await?;
}
log::info!("Connected to search relays");
// Establish connection to relays
client.connect().await;
Ok(())
}
async fn handle_nostr_notifications(event_tx: &Sender<Event>) -> Result<(), Error> {
let client = nostr_client();
let channel = global_channel();
let auto_close = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let mut notifications = client.notifications();
while let Ok(notification) = notifications.recv().await {
let RelayPoolNotification::Message { message, .. } = notification else {
continue;
};
let RelayMessage::Event { event, .. } = message else {
continue;
};
// Skip events that have already been processed
if !processed_events().write().await.insert(event.id) {
continue;
}
match event.kind {
Kind::RelayList => {
// Get metadata for event's pubkey that matches the current user's pubkey
if let Ok(true) = is_from_current_user(&event).await {
let sub_id = SubscriptionId::new("metadata");
let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::InboxRelays];
let filter = Filter::new().kinds(kinds).author(event.pubkey).limit(10);
client
.subscribe_with_id(sub_id, filter, Some(auto_close))
.await
.ok();
}
}
Kind::InboxRelays => {
if let Ok(true) = is_from_current_user(&event).await {
// Get all inbox relays
let relays = event
.tags
.filter_standardized(TagKind::Relay)
.filter_map(|t| {
if let TagStandard::Relay(url) = t {
Some(url.to_owned())
} else {
None
}
})
.collect_vec();
if !relays.is_empty() {
// Add relays to nostr client
for relay in relays.iter() {
_ = client.add_relay(relay).await;
_ = client.connect_relay(relay).await;
}
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(event.pubkey);
let sub_id = SubscriptionId::new("gift-wrap");
// Notify the UI that the current user has set up the DM relays
channel.0.send(NostrSignal::DmRelaysFound).await.ok();
if client
.subscribe_with_id_to(relays.clone(), sub_id, filter, None)
.await
.is_ok()
{
log::info!("Subscribing to messages in: {relays:?}");
}
}
}
}
Kind::ContactList => {
if let Ok(true) = is_from_current_user(&event).await {
let public_keys: Vec<PublicKey> = event.tags.public_keys().copied().collect();
let kinds = vec![Kind::Metadata, Kind::ContactList];
let lens = public_keys.len() * kinds.len();
let filter = Filter::new().limit(lens).authors(public_keys).kinds(kinds);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(auto_close))
.await
.ok();
}
}
Kind::Metadata => {
channel
.0
.send(NostrSignal::Metadata(event.into_owned()))
.await
.ok();
}
Kind::GiftWrap => {
event_tx.send(event.into_owned()).await.ok();
}
_ => {}
}
}
Ok(())
}
async fn get_nip65_relays(public_key: PublicKey) -> Result<(), Error> {
let client = nostr_client();
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let sub_id = SubscriptionId::new("nip65-relays");
let filter = Filter::new()
.kind(Kind::RelayList)
.author(public_key)
.limit(1);
client.subscribe_with_id(sub_id, filter, Some(opts)).await?;
Ok(())
}
async fn is_from_current_user(event: &Event) -> Result<bool, Error> {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
Ok(public_key == event.pubkey)
}
async fn sync_data_for_pubkeys(public_keys: BTreeSet<PublicKey>) {
let client = nostr_client();
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let kinds = vec![Kind::Metadata, Kind::ContactList];
let filter = Filter::new()
.limit(public_keys.len() * kinds.len())
.authors(public_keys)
.kinds(kinds);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await
.ok();
}
/// Stores an unwrapped event in local database with reference to original
async fn set_unwrapped(root: EventId, unwrapped: &Event) -> Result<(), Error> {
let client = nostr_client();
// Save unwrapped event
client.database().save_event(unwrapped).await?;
// Create a reference event pointing to the unwrapped event
let event = EventBuilder::new(Kind::ApplicationSpecificData, "")
.tags(vec![Tag::identifier(root), Tag::event(unwrapped.id)])
.sign(&Keys::generate())
.await?;
// Save reference event
client.database().save_event(&event).await?;
Ok(())
}
/// Retrieves a previously unwrapped event from local database
async fn get_unwrapped(root: EventId) -> Result<Event, Error> {
let client = nostr_client();
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(root)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
let target_id = event.tags.event_ids().collect_vec()[0];
if let Some(event) = client.database().event_by_id(target_id).await? {
Ok(event)
} else {
Err(anyhow!("Event not found."))
}
} else {
Err(anyhow!("Event is not cached yet."))
}
}
/// Unwraps a gift-wrapped event and processes its contents.
async fn unwrap_gift(gift: &Event, pubkey_tx: &Sender<PublicKey>) -> bool {
let client = nostr_client();
let channel = global_channel();
let mut is_cached = false;
let event = match get_unwrapped(gift.id).await {
Ok(event) => {
is_cached = true;
event
}
Err(_) => {
match client.unwrap_gift_wrap(gift).await {
Ok(unwrap) => {
// Sign the unwrapped event with a RANDOM KEYS
let Ok(unwrapped) = unwrap.rumor.sign_with_keys(&Keys::generate()) else {
log::error!("Failed to sign event");
return false;
};
// Save this event to the database for future use.
if let Err(e) = set_unwrapped(gift.id, &unwrapped).await {
log::warn!("Failed to cache unwrapped event: {e}")
}
unwrapped
}
Err(e) => {
log::error!("Failed to unwrap event: {e}");
return false;
}
}
}
};
// Send all pubkeys to the metadata batch to sync data
for public_key in event.all_pubkeys() {
pubkey_tx.send(public_key).await.ok();
}
// Send a notify to GPUI if this is a new message
if starting_time() <= &event.created_at {
channel.0.send(NostrSignal::GiftWrap(event)).await.ok();
}
is_cached
}

View File

@@ -2,10 +2,10 @@ use std::time::Duration;
use anyhow::Error;
use client_keys::ClientKeys;
use common::display::DisplayProfile;
use common::display::ReadableProfile;
use common::handle_auth::CoopAuthUrlHandler;
use global::constants::ACCOUNT_IDENTIFIER;
use global::nostr_client;
use global::constants::{ACCOUNT_IDENTIFIER, BUNKER_TIMEOUT};
use global::{ingester, nostr_client, IngesterSignal};
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
@@ -13,7 +13,6 @@ use gpui::{
StatefulInteractiveElement, Styled, Task, WeakEntity, Window,
};
use i18n::{shared_t, t};
use identity::Identity;
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use theme::ActiveTheme;
@@ -25,6 +24,8 @@ use ui::input::{InputState, TextInput};
use ui::popup_menu::PopupMenu;
use ui::{h_flex, v_flex, ContextModal, Disableable, Sizable, StyledExt};
use crate::chatspace::ChatSpace;
pub fn init(
secret: String,
profile: Profile,
@@ -69,7 +70,7 @@ impl Account {
self.nostr_connect(uri, window, cx);
}
} else if self.is_extension {
self.proxy(window, cx);
self.set_proxy(window, cx);
} else if let Ok(enc) = EncryptedSecretKey::from_bech32(&self.stored_secret) {
self.keys(enc, window, cx);
} else {
@@ -82,8 +83,7 @@ impl Account {
let client_keys = ClientKeys::global(cx);
let app_keys = client_keys.read(cx).keys();
let secs = 30;
let timeout = Duration::from_secs(secs);
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
let mut signer = NostrConnect::new(uri, app_keys, timeout, None).unwrap();
// Handle auth url with the default browser
@@ -109,8 +109,8 @@ impl Account {
.detach();
}
fn proxy(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
Identity::start_browser_proxy(cx);
fn set_proxy(&mut self, window: &mut Window, cx: &mut Context<Self>) {
ChatSpace::proxy_signer(window, cx);
}
fn keys(&mut self, enc: EncryptedSecretKey, window: &mut Window, cx: &mut Context<Self>) {
@@ -239,26 +239,23 @@ impl Account {
.detach();
}
fn logout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
fn logout(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
cx.background_spawn(async move {
let client = nostr_client();
let ingester = ingester();
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ACCOUNT_IDENTIFIER);
// Delete account
client.database().delete(filter).await?;
client.database().delete(filter).await.ok();
Ok(())
});
// Unset the client's signer
client.unset_signer().await;
cx.spawn_in(window, async move |_this, cx| {
if task.await.is_ok() {
cx.update(|_window, cx| {
cx.restart();
})
.ok();
}
// Notify the channel about the signer being unset
ingester.send(IngesterSignal::SignerUnset).await;
})
.detach();
}

View File

@@ -1,9 +1,9 @@
use std::collections::{BTreeSet, HashMap};
use std::collections::HashMap;
use anyhow::anyhow;
use common::display::DisplayProfile;
use common::display::{ReadableProfile, ReadableTimestamp};
use common::nip96::nip96_upload;
use global::nostr_client;
use global::{nostr_client, sent_ids};
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, list, px, red, relative, rems, svg, white, Action, AnyElement, App, AppContext,
@@ -14,7 +14,6 @@ use gpui::{
};
use gpui_tokio::Tokio;
use i18n::{shared_t, t};
use identity::Identity;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use registry::message::RenderedMessage;
@@ -31,7 +30,6 @@ use ui::dock_area::panel::{Panel, PanelEvent};
use ui::emoji_picker::EmojiPicker;
use ui::input::{InputEvent, InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::notification::Notification;
use ui::popup_menu::PopupMenu;
use ui::text::RenderedText;
use ui::{
@@ -56,7 +54,7 @@ pub struct Chat {
// Chat Room
room: Entity<Room>,
list_state: ListState,
messages: BTreeSet<RenderedMessage>,
messages: Vec<RenderedMessage>,
rendered_texts_by_id: HashMap<EventId, RenderedText>,
reports_by_id: HashMap<EventId, Vec<SendReport>>,
// New Message
@@ -107,7 +105,7 @@ impl Chat {
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
window.push_notification(e.to_string(), cx);
})
.ok();
}
@@ -138,10 +136,10 @@ impl Chat {
// Subscribe to room events
cx.subscribe_in(&room, window, move |this, _, signal, window, cx| {
match signal {
RoomSignal::NewMessage(event) => {
if !this.is_seen_message(event) {
RoomSignal::NewMessage((gift_wrap_id, event)) => {
if !this.is_sent_by_coop(gift_wrap_id) {
this.insert_message(event, cx);
};
}
}
RoomSignal::Refresh => {
this.load_messages(window, cx);
@@ -156,7 +154,7 @@ impl Chat {
focus_handle: cx.focus_handle(),
uploading: false,
sending: false,
messages: BTreeSet::new(),
messages: Vec::new(),
rendered_texts_by_id: HashMap::new(),
reports_by_id: HashMap::new(),
room,
@@ -219,14 +217,10 @@ impl Chat {
content
}
/// Check if the event is a seen message
fn is_seen_message(&self, event: &Event) -> bool {
if let Some(message) = self.messages.last() {
let duration = event.created_at.as_u64() - message.created_at.as_u64();
message.content == event.content && message.author == event.pubkey && duration <= 20
} else {
false
}
/// Check if the event is sent by Coop
fn is_sent_by_coop(&self, gift_wrap_id: &EventId) -> bool {
let sent_ids = sent_ids();
sent_ids.read_blocking().contains(gift_wrap_id)
}
/// Set the sending state of the chat panel
@@ -263,7 +257,7 @@ impl Chat {
// Get the current room entity
let room = self.room.read(cx);
let identity = Identity::read_global(cx).public_key();
let identity = Registry::read_global(cx).identity(cx).public_key();
// Create a temporary message for optimistic update
let temp_message = room.create_temp_message(identity, &content, replies.as_ref());
@@ -346,7 +340,7 @@ impl Chat {
let new_len = 1;
// Extend the messages list with the new events
self.messages.insert(event.into());
self.messages.push(event.into());
// Update list state with the new messages
self.list_state.splice(old_len..old_len, new_len);
@@ -360,11 +354,12 @@ impl Chat {
E::Item: Into<RenderedMessage>,
{
let old_len = self.messages.len();
let events: Vec<_> = events.into_iter().map(Into::into).collect();
let events: Vec<RenderedMessage> = events.into_iter().map(Into::into).collect();
let new_len = events.len();
// Extend the messages list with the new events
self.messages.extend(events);
self.messages.sort_by_key(|ev| ev.created_at);
// Update list state with the new messages
self.list_state.splice(old_len..old_len, new_len);
@@ -532,7 +527,7 @@ impl Chat {
window: &mut Window,
cx: &mut Context<Self>,
) -> Stateful<Div> {
let Some(message) = self.messages.iter().nth(ix) else {
let Some(message) = self.messages.get(ix) else {
return div().id(ix);
};
@@ -591,7 +586,7 @@ impl Chat {
.text_color(cx.theme().text)
.child(author.display_name()),
)
.child(div().child(message.ago()))
.child(div().child(message.created_at.to_human_time()))
.when_some(is_sent_success, |this, status| {
this.when(status, |this| {
this.child(self.render_message_sent(&id, cx))

View File

@@ -28,8 +28,8 @@ impl Subject {
cx.new(|_| Self { input })
}
pub fn new_subject(&self, cx: &App) -> SharedString {
self.input.read(cx).value().clone()
pub fn new_subject(&self, cx: &App) -> String {
self.input.read(cx).value().to_string()
}
}

View File

@@ -2,7 +2,7 @@ use std::ops::Range;
use std::time::Duration;
use anyhow::{anyhow, Error};
use common::display::{DisplayProfile, TextUtils};
use common::display::{ReadableProfile, TextUtils};
use common::nip05::nip05_profile;
use global::constants::BOOTSTRAP_RELAYS;
use global::nostr_client;

View File

@@ -2,7 +2,7 @@ use std::time::Duration;
use client_keys::ClientKeys;
use common::handle_auth::CoopAuthUrlHandler;
use global::constants::ACCOUNT_IDENTIFIER;
use global::constants::{ACCOUNT_IDENTIFIER, BUNKER_TIMEOUT};
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -264,8 +264,7 @@ impl Login {
let client_keys = ClientKeys::global(cx);
let app_keys = client_keys.read(cx).keys();
let secs = 30;
let timeout = Duration::from_secs(secs);
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
let mut signer = NostrConnect::new(uri, app_keys, timeout, None).unwrap();
// Handle auth url with the default browser
@@ -273,7 +272,7 @@ impl Login {
// Start countdown
cx.spawn_in(window, async move |this, cx| {
for i in (0..=secs).rev() {
for i in (0..=BUNKER_TIMEOUT).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);

View File

@@ -4,11 +4,11 @@ pub mod chat;
pub mod compose;
pub mod edit_profile;
pub mod login;
pub mod messaging_relays;
pub mod new_account;
pub mod onboarding;
pub mod preferences;
pub mod screening;
pub mod setup_relay;
pub mod sidebar;
pub mod user_profile;
pub mod welcome;

View File

@@ -12,7 +12,6 @@ use gpui::{
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
};
use i18n::{shared_t, t};
use identity::Identity;
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
@@ -21,7 +20,7 @@ use ui::dock_area::panel::{Panel, PanelEvent};
use ui::popup_menu::PopupMenu;
use ui::{divider, h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt};
use crate::chatspace;
use crate::chatspace::{self, ChatSpace};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
Onboarding::new(window, cx)
@@ -159,8 +158,8 @@ impl Onboarding {
.detach();
}
fn set_proxy(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
Identity::start_browser_proxy(cx);
fn set_proxy(&mut self, window: &mut Window, cx: &mut Context<Self>) {
ChatSpace::proxy_signer(window, cx);
}
fn write_uri_to_disk(&mut self, uri: &NostrConnectURI, cx: &mut Context<Self>) {

View File

@@ -1,11 +1,11 @@
use common::display::DisplayProfile;
use common::display::ReadableProfile;
use gpui::http_client::Url;
use gpui::{
div, px, relative, rems, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window,
};
use i18n::t;
use identity::Identity;
use nostr_sdk::prelude::*;
use registry::Registry;
use settings::AppSettings;
use theme::ActiveTheme;
@@ -16,7 +16,7 @@ use ui::modal::ModalButtonProps;
use ui::switch::Switch;
use ui::{v_flex, ContextModal, IconName, Sizable, Size, StyledExt};
use crate::views::{edit_profile, messaging_relays};
use crate::views::{edit_profile, setup_relay};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
Preferences::new(window, cx)
@@ -89,7 +89,7 @@ impl Preferences {
fn open_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let title = SharedString::new(t!("relays.modal_title"));
let view = messaging_relays::init(window, cx);
let view = setup_relay::init(Kind::InboxRelays, window, cx);
let weak_view = view.downgrade();
window.open_modal(cx, move |this, _window, _cx| {
@@ -115,8 +115,7 @@ impl Preferences {
impl Render for Preferences {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let input_state = self.media_input.downgrade();
let identity = Identity::read_global(cx).public_key();
let profile = Registry::read_global(cx).get_person(&identity, cx);
let profile = Registry::read_global(cx).identity(cx);
let backup_messages = AppSettings::get_backup_messages(cx);
let screening = AppSettings::get_screening(cx);

View File

@@ -1,4 +1,4 @@
use common::display::{shorten_pubkey, DisplayProfile};
use common::display::{shorten_pubkey, ReadableProfile};
use common::nip05::nip05_verify;
use global::nostr_client;
use gpui::{
@@ -7,41 +7,35 @@ use gpui::{
};
use gpui_tokio::Tokio;
use i18n::{shared_t, t};
use identity::Identity;
use nostr_sdk::prelude::*;
use registry::Registry;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
Screening::new(public_key, window, cx)
cx.new(|cx| Screening::new(public_key, window, cx))
}
pub struct Screening {
public_key: PublicKey,
profile: Profile,
verified: bool,
followed: bool,
dm_relays: bool,
mutual_contacts: usize,
_tasks: SmallVec<[Task<()>; 1]>,
}
impl Screening {
pub fn new(public_key: PublicKey, _window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|_| Self {
public_key,
verified: false,
followed: false,
dm_relays: false,
mutual_contacts: 0,
})
}
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let registry = Registry::read_global(cx);
let identity = registry.identity(cx).public_key();
let profile = registry.get_person(&public_key, cx);
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let identity = Identity::read_global(cx).public_key();
let public_key = self.public_key;
let mut tasks = smallvec![];
let check_trust_score: Task<(bool, usize, bool)> = cx.background_spawn(async move {
let client = nostr_client();
@@ -69,7 +63,7 @@ impl Screening {
(is_follow, mutual_contacts, dm_relays)
});
let verify_nip05 = if let Some(address) = self.address(cx) {
let verify_nip05 = if let Some(address) = profile.metadata().nip05 {
Some(Tokio::spawn(cx, async move {
nip05_verify(public_key, &address).await.unwrap_or(false)
}))
@@ -77,47 +71,54 @@ impl Screening {
None
};
cx.spawn_in(window, async move |this, cx| {
let (followed, mutual_contacts, dm_relays) = check_trust_score.await;
tasks.push(
// Load all necessary data
cx.spawn_in(window, async move |this, cx| {
let (followed, mutual_contacts, dm_relays) = check_trust_score.await;
this.update(cx, |this, cx| {
this.followed = followed;
this.mutual_contacts = mutual_contacts;
this.dm_relays = dm_relays;
cx.notify();
})
.ok();
this.update(cx, |this, cx| {
this.followed = followed;
this.mutual_contacts = mutual_contacts;
this.dm_relays = dm_relays;
cx.notify();
})
.ok();
// Update the NIP05 verification status if user has NIP05 address
if let Some(task) = verify_nip05 {
if let Ok(verified) = task.await {
this.update(cx, |this, cx| {
this.verified = verified;
cx.notify();
})
.ok();
// Update the NIP05 verification status if user has NIP05 address
if let Some(task) = verify_nip05 {
if let Ok(verified) = task.await {
this.update(cx, |this, cx| {
this.verified = verified;
cx.notify();
})
.ok();
}
}
}
})
.detach();
}),
);
Self {
profile,
verified: false,
followed: false,
dm_relays: false,
mutual_contacts: 0,
_tasks: tasks,
}
}
fn profile(&self, cx: &Context<Self>) -> Profile {
let registry = Registry::read_global(cx);
registry.get_person(&self.public_key, cx)
}
fn address(&self, cx: &Context<Self>) -> Option<String> {
self.profile(cx).metadata().nip05
fn address(&self, _cx: &Context<Self>) -> Option<String> {
self.profile.metadata().nip05
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut App) {
let Ok(bech32) = self.public_key.to_bech32();
let Ok(bech32) = self.profile.public_key().to_bech32();
cx.open_url(&format!("https://njump.me/{bech32}"));
}
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let public_key = self.public_key;
let public_key = self.profile.public_key();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let client = nostr_client();
let builder = EventBuilder::report(
@@ -145,8 +146,7 @@ impl Screening {
impl Render for Screening {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let proxy = AppSettings::get_proxy_user_avatars(cx);
let profile = self.profile(cx);
let shorten_pubkey = shorten_pubkey(profile.public_key(), 8);
let shorten_pubkey = shorten_pubkey(self.profile.public_key(), 8);
v_flex()
.gap_4()
@@ -156,12 +156,12 @@ impl Render for Screening {
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(4.)))
.child(Avatar::new(self.profile.avatar_url(proxy)).size(rems(4.)))
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(profile.display_name()),
.child(self.profile.display_name()),
),
)
.child(

View File

@@ -10,7 +10,9 @@ use gpui::{
TextAlign, UniformList, Window,
};
use i18n::{shared_t, t};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use registry::Registry;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::button::{Button, ButtonRounded, ButtonVariants};
@@ -18,21 +20,23 @@ use ui::input::{InputEvent, InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<MessagingRelays> {
cx.new(|cx| MessagingRelays::new(window, cx))
pub fn init(kind: Kind, window: &mut Window, cx: &mut App) -> Entity<SetupRelay> {
cx.new(|cx| SetupRelay::new(kind, window, cx))
}
pub fn relay_button() -> impl IntoElement {
pub fn setup_nip17_relay<T>(label: T) -> impl IntoElement
where
T: Into<SharedString>,
{
div().child(
Button::new("dm-relays")
Button::new("setup-relays")
.icon(IconName::Info)
.label(t!("relays.button_label"))
.label(label)
.warning()
.xsmall()
.rounded(ButtonRounded::Full)
.on_click(move |_, window, cx| {
let title = SharedString::new(t!("relays.modal_title"));
let view = cx.new(|cx| MessagingRelays::new(window, cx));
let view = cx.new(|cx| SetupRelay::new(Kind::InboxRelays, window, cx));
let weak_view = view.downgrade();
window.open_modal(cx, move |modal, _window, _cx| {
@@ -40,7 +44,7 @@ pub fn relay_button() -> impl IntoElement {
modal
.confirm()
.title(title.clone())
.title(shared_t!("relays.modal_title"))
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
@@ -57,60 +61,41 @@ pub fn relay_button() -> impl IntoElement {
)
}
pub struct MessagingRelays {
pub struct SetupRelay {
input: Entity<InputState>,
relays: Vec<RelayUrl>,
error: Option<SharedString>,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
_subscriptions: SmallVec<[Subscription; 1]>,
_tasks: SmallVec<[Task<()>; 1]>,
}
impl MessagingRelays {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
impl SetupRelay {
pub fn new(kind: Kind, window: &mut Window, cx: &mut Context<Self>) -> Self {
let identity = Registry::read_global(cx).identity(cx).public_key();
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
let mut subscriptions = smallvec![];
let mut tasks = smallvec![];
subscriptions.push(cx.observe_new::<Self>(move |this, window, cx| {
if let Some(window) = window {
this.load(window, cx);
}
}));
subscriptions.push(cx.subscribe_in(
&input,
window,
move |this: &mut Self, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add(window, cx);
}
},
));
Self {
input,
subscriptions,
relays: vec![],
error: None,
}
}
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
let load_relay = cx.background_spawn(async move {
let client = nostr_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
let filter = Filter::new().kind(kind).author(identity).limit(1);
if let Some(event) = client.database().query(filter).await?.first() {
let relays = event
.tags
.filter(TagKind::Relay)
.filter_map(|tag| RelayUrl::parse(tag.content()?).ok())
.collect::<Vec<_>>();
.iter()
.filter_map(|tag| tag.as_standardized())
.filter_map(|tag| {
if let TagStandard::RelayMetadata { relay_url, .. } = tag {
Some(relay_url.to_owned())
} else if let TagStandard::Relay(url) = tag {
Some(url.to_owned())
} else {
None
}
})
.collect_vec();
Ok(relays)
} else {
@@ -118,16 +103,39 @@ impl MessagingRelays {
}
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(relays) = task.await {
this.update(cx, |this, cx| {
this.relays = relays;
cx.notify();
})
.ok();
}
})
.detach();
tasks.push(
// Load user's relays in the local database
cx.spawn_in(window, async move |this, cx| {
if let Ok(relays) = load_relay.await {
this.update(cx, |this, cx| {
this.relays = relays;
cx.notify();
})
.ok();
}
}),
);
subscriptions.push(
// Subscribe to user's input events
cx.subscribe_in(
&input,
window,
move |this: &mut Self, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add(window, cx);
}
},
),
);
Self {
input,
relays: vec![],
error: None,
_subscriptions: subscriptions,
_tasks: tasks,
}
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -283,11 +291,11 @@ impl MessagingRelays {
.justify_center()
.text_sm()
.text_align(TextAlign::Center)
.child(SharedString::new(t!("relays.add_some_relays")))
.child(shared_t!("relays.add_some_relays"))
}
}
impl Render for MessagingRelays {
impl Render for SetupRelay {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()

View File

@@ -2,8 +2,8 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
div, rems, App, ClickEvent, Div, InteractiveElement, IntoElement, ParentElement as _,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window,
};
use i18n::t;
use nostr_sdk::prelude::*;
@@ -23,7 +23,6 @@ use crate::views::screening;
#[derive(IntoElement)]
pub struct RoomListItem {
ix: usize,
base: Div,
room_id: Option<u64>,
public_key: Option<PublicKey>,
name: Option<SharedString>,
@@ -38,7 +37,6 @@ impl RoomListItem {
pub fn new(ix: usize) -> Self {
Self {
ix,
base: h_flex().h_9().w_full().px_1p5().gap_2(),
room_id: None,
public_key: None,
name: None,
@@ -59,18 +57,18 @@ impl RoomListItem {
self
}
pub fn name(mut self, name: SharedString) -> Self {
self.name = Some(name);
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
self.name = Some(name.into());
self
}
pub fn avatar(mut self, avatar: SharedString) -> Self {
self.avatar = Some(avatar);
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self {
self.avatar = Some(avatar.into());
self
}
pub fn created_at(mut self, created_at: SharedString) -> Self {
self.created_at = Some(created_at);
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
self.created_at = Some(created_at.into());
self
}
@@ -111,9 +109,12 @@ impl RenderOnce for RoomListItem {
self.handler,
)
else {
return self
.base
return h_flex()
.id(self.ix)
.h_9()
.w_full()
.px_1p5()
.gap_2()
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
.child(
div()
@@ -125,8 +126,12 @@ impl RenderOnce for RoomListItem {
);
};
self.base
h_flex()
.id(self.ix)
.h_9()
.w_full()
.px_1p5()
.gap_2()
.text_sm()
.rounded(cx.theme().radius)
.when(!hide_avatar, |this| {

View File

@@ -4,7 +4,7 @@ use std::time::Duration;
use anyhow::{anyhow, Error};
use common::debounced_delay::DebouncedDelay;
use common::display::TextUtils;
use common::display::{ReadableTimestamp, TextUtils};
use global::constants::{BOOTSTRAP_RELAYS, SEARCH_RELAYS};
use global::nostr_client;
use gpui::prelude::FluentBuilder;
@@ -15,12 +15,11 @@ use gpui::{
};
use gpui_tokio::Tokio;
use i18n::t;
use identity::Identity;
use itertools::Itertools;
use list_item::RoomListItem;
use nostr_sdk::prelude::*;
use registry::room::{Room, RoomKind};
use registry::{Registry, RegistrySignal};
use registry::{Registry, RegistryEvent};
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
@@ -82,7 +81,7 @@ impl Sidebar {
&chats,
window,
move |this, _chats, event, _window, cx| {
if let RegistrySignal::NewRequest(kind) = event {
if let RegistryEvent::NewRequest(kind) = event {
this.indicator.update(cx, |this, cx| {
*this = Some(kind.to_owned());
cx.notify();
@@ -211,7 +210,7 @@ impl Sidebar {
window: &mut Window,
cx: &mut Context<Self>,
) {
let identity = Identity::read_global(cx).public_key();
let identity = Registry::read_global(cx).identity(cx).public_key();
let query = query.to_owned();
let query_cloned = query.clone();
@@ -271,7 +270,7 @@ impl Sidebar {
}
fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
let identity = Identity::read_global(cx).public_key();
let identity = Registry::read_global(cx).identity(cx).public_key();
let address = query.to_owned();
let task = Tokio::spawn(cx, async move {
@@ -325,7 +324,7 @@ impl Sidebar {
return;
};
let identity = Identity::read_global(cx).public_key();
let identity = Registry::read_global(cx).identity(cx).public_key();
let task: Task<Result<Room, Error>> = cx.background_spawn(async move {
// Create a gift wrap event to represent as room
Self::create_temp_room(identity, public_key).await
@@ -553,7 +552,7 @@ impl Sidebar {
.room_id(room_id)
.name(this.display_name(cx))
.avatar(this.display_image(proxy, cx))
.created_at(this.ago())
.created_at(this.created_at.to_ago())
.public_key(this.members[0])
.kind(this.kind)
.on_click(handler),

View File

@@ -1,6 +1,6 @@
use std::time::Duration;
use common::display::DisplayProfile;
use common::display::ReadableProfile;
use common::nip05::nip05_verify;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
@@ -9,40 +9,35 @@ use gpui::{
ParentElement, Render, SharedString, Styled, Task, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
use identity::Identity;
use i18n::{shared_t, t};
use nostr_sdk::prelude::*;
use registry::Registry;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::{h_flex, v_flex, Disableable, Icon, IconName, Sizable, StyledExt};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<UserProfile> {
UserProfile::new(public_key, window, cx)
cx.new(|cx| UserProfile::new(public_key, window, cx))
}
pub struct UserProfile {
public_key: PublicKey,
profile: Profile,
followed: bool,
verified: bool,
copied: bool,
_tasks: SmallVec<[Task<()>; 1]>,
}
impl UserProfile {
pub fn new(public_key: PublicKey, _window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|_| Self {
public_key,
followed: false,
verified: false,
copied: false,
})
}
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let registry = Registry::read_global(cx);
let identity = registry.identity(cx).public_key();
let profile = registry.get_person(&public_key, cx);
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let identity = Identity::read_global(cx).public_key();
let public_key = self.public_key;
let mut tasks = smallvec![];
let check_follow: Task<bool> = cx.background_spawn(async move {
let client = nostr_client();
@@ -55,7 +50,7 @@ impl UserProfile {
client.database().count(filter).await.unwrap_or(0) >= 1
});
let verify_nip05 = if let Some(address) = self.address(cx) {
let verify_nip05 = if let Some(address) = profile.metadata().nip05 {
Some(Tokio::spawn(cx, async move {
nip05_verify(public_key, &address).await.unwrap_or(false)
}))
@@ -63,41 +58,46 @@ impl UserProfile {
None
};
cx.spawn_in(window, async move |this, cx| {
let followed = check_follow.await;
tasks.push(
// Load user profile data
cx.spawn_in(window, async move |this, cx| {
let followed = check_follow.await;
// Update the followed status
this.update(cx, |this, cx| {
this.followed = followed;
cx.notify();
})
.ok();
// Update the followed status
this.update(cx, |this, cx| {
this.followed = followed;
cx.notify();
})
.ok();
// Update the NIP05 verification status if user has NIP05 address
if let Some(task) = verify_nip05 {
if let Ok(verified) = task.await {
this.update(cx, |this, cx| {
this.verified = verified;
cx.notify();
})
.ok();
// Update the NIP05 verification status if user has NIP05 address
if let Some(task) = verify_nip05 {
if let Ok(verified) = task.await {
this.update(cx, |this, cx| {
this.verified = verified;
cx.notify();
})
.ok();
}
}
}
})
.detach();
}),
);
Self {
profile,
followed: false,
verified: false,
copied: false,
_tasks: tasks,
}
}
fn profile(&self, cx: &Context<Self>) -> Profile {
let registry = Registry::read_global(cx);
registry.get_person(&self.public_key, cx)
}
fn address(&self, cx: &Context<Self>) -> Option<String> {
self.profile(cx).metadata().nip05
fn address(&self, _cx: &Context<Self>) -> Option<String> {
self.profile.metadata().nip05
}
fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.public_key.to_bech32();
let Ok(bech32) = self.profile.public_key().to_bech32();
let item = ClipboardItem::new_string(bech32);
cx.write_to_clipboard(item);
@@ -128,9 +128,8 @@ impl UserProfile {
impl Render for UserProfile {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let proxy = AppSettings::get_proxy_user_avatars(cx);
let profile = self.profile(cx);
let Ok(bech32) = profile.public_key().to_bech32();
let Ok(bech32) = self.profile.public_key().to_bech32();
let shared_bech32 = SharedString::new(bech32);
v_flex()
@@ -141,14 +140,14 @@ impl Render for UserProfile {
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(4.)))
.child(Avatar::new(self.profile.avatar_url(proxy)).size(rems(4.)))
.child(
v_flex()
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(profile.display_name()),
.child(self.profile.display_name()),
)
.when_some(self.address(cx), |this, address| {
this.child(
@@ -183,7 +182,7 @@ impl Render for UserProfile {
.bg(cx.theme().elevated_surface_background)
.text_xs()
.font_semibold()
.child(SharedString::new(t!("profile.unknown"))),
.child(shared_t!("profile.unknown")),
)
}),
)
@@ -235,7 +234,7 @@ impl Render for UserProfile {
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("profile.label_bio"))),
.child(shared_t!("profile.label_bio")),
)
.child(
div()
@@ -243,7 +242,7 @@ impl Render for UserProfile {
.rounded_md()
.bg(cx.theme().elevated_surface_background)
.child(
profile
self.profile
.metadata()
.about
.unwrap_or(t!("profile.no_bio").to_string()),