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:
@@ -1,21 +1,27 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use chrono::{Local, TimeZone};
|
||||
use global::constants::IMAGE_RESIZE_SERVICE;
|
||||
use gpui::{Image, ImageFormat, SharedString};
|
||||
use gpui::{Image, ImageFormat};
|
||||
use nostr_sdk::prelude::*;
|
||||
use qrcode::render::svg;
|
||||
use qrcode::QrCode;
|
||||
|
||||
const NOW: &str = "now";
|
||||
const SECONDS_IN_MINUTE: i64 = 60;
|
||||
const MINUTES_IN_HOUR: i64 = 60;
|
||||
const HOURS_IN_DAY: i64 = 24;
|
||||
const DAYS_IN_MONTH: i64 = 30;
|
||||
const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png";
|
||||
|
||||
pub trait DisplayProfile {
|
||||
fn avatar_url(&self, proxy: bool) -> SharedString;
|
||||
fn display_name(&self) -> SharedString;
|
||||
pub trait ReadableProfile {
|
||||
fn avatar_url(&self, proxy: bool) -> String;
|
||||
fn display_name(&self) -> String;
|
||||
}
|
||||
|
||||
impl DisplayProfile for Profile {
|
||||
fn avatar_url(&self, proxy: bool) -> SharedString {
|
||||
impl ReadableProfile for Profile {
|
||||
fn avatar_url(&self, proxy: bool) -> String {
|
||||
self.metadata()
|
||||
.picture
|
||||
.as_ref()
|
||||
@@ -25,7 +31,6 @@ impl DisplayProfile for Profile {
|
||||
format!(
|
||||
"{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1"
|
||||
)
|
||||
.into()
|
||||
} else {
|
||||
picture.into()
|
||||
}
|
||||
@@ -33,7 +38,7 @@ impl DisplayProfile for Profile {
|
||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||
}
|
||||
|
||||
fn display_name(&self) -> SharedString {
|
||||
fn display_name(&self) -> String {
|
||||
if let Some(display_name) = self.metadata().display_name.as_ref() {
|
||||
if !display_name.is_empty() {
|
||||
return display_name.into();
|
||||
@@ -50,6 +55,51 @@ impl DisplayProfile for Profile {
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ReadableTimestamp {
|
||||
fn to_human_time(&self) -> String;
|
||||
fn to_ago(&self) -> String;
|
||||
}
|
||||
|
||||
impl ReadableTimestamp for Timestamp {
|
||||
fn to_human_time(&self) -> String {
|
||||
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "9999".into(),
|
||||
};
|
||||
|
||||
let now = Local::now();
|
||||
let input_date = input_time.date_naive();
|
||||
let now_date = now.date_naive();
|
||||
let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
|
||||
|
||||
let time_format = input_time.format("%H:%M %p");
|
||||
|
||||
match input_date {
|
||||
date if date == now_date => format!("Today at {time_format}"),
|
||||
date if date == yesterday_date => format!("Yesterday at {time_format}"),
|
||||
_ => format!("{}, {time_format}", input_time.format("%d/%m/%y")),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_ago(&self) -> String {
|
||||
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "1m".into(),
|
||||
};
|
||||
|
||||
let now = Local::now();
|
||||
let duration = now.signed_duration_since(input_time);
|
||||
|
||||
match duration {
|
||||
d if d.num_seconds() < SECONDS_IN_MINUTE => NOW.into(),
|
||||
d if d.num_minutes() < MINUTES_IN_HOUR => format!("{}m", d.num_minutes()),
|
||||
d if d.num_hours() < HOURS_IN_DAY => format!("{}h", d.num_hours()),
|
||||
d if d.num_days() < DAYS_IN_MONTH => format!("{}d", d.num_days()),
|
||||
_ => input_time.format("%b %d").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait TextUtils {
|
||||
fn to_public_key(&self) -> Result<PublicKey, Error>;
|
||||
fn to_qr(&self) -> Option<Arc<Image>>;
|
||||
@@ -84,7 +134,7 @@ impl<T: AsRef<str>> TextUtils for T {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> SharedString {
|
||||
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
|
||||
let Ok(pubkey) = public_key.to_bech32();
|
||||
|
||||
format!(
|
||||
@@ -92,5 +142,4 @@ pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> SharedString {
|
||||
&pubkey[0..(len + 1)],
|
||||
&pubkey[pubkey.len() - len..]
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
34
crates/coop/src/actions.rs
Normal file
34
crates/coop/src/actions.rs
Normal 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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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| {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -8,15 +8,16 @@ pub const ACCOUNT_IDENTIFIER: &str = "coop:user";
|
||||
pub const SETTINGS_D: &str = "coop:settings";
|
||||
|
||||
/// Bootstrap Relays.
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nos.social",
|
||||
"wss://user.kindpag.es",
|
||||
"wss://purplepag.es",
|
||||
];
|
||||
|
||||
/// Search Relays.
|
||||
pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.nostr.band"];
|
||||
pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"];
|
||||
|
||||
/// NIP65 Relays. Used for new account
|
||||
pub const NIP65_RELAYS: [&str; 4] = [
|
||||
@@ -27,14 +28,20 @@ pub const NIP65_RELAYS: [&str; 4] = [
|
||||
];
|
||||
|
||||
/// Messaging Relays. Used for new account
|
||||
pub const NIP17_RELAYS: [&str; 2] = ["wss://nip17.com", "wss://relay.0xchat.com"];
|
||||
pub const NIP17_RELAYS: [&str; 2] = ["wss://nip17.com", "wss://auth.nostr1.com"];
|
||||
|
||||
/// Default relay for Nostr Connect
|
||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
|
||||
|
||||
/// Default retry count for fetching NIP-17 relays
|
||||
pub const TOTAL_RETRY: u64 = 2;
|
||||
|
||||
/// Default timeout (in seconds) for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
|
||||
|
||||
/// Default timeout (in seconds) for Nostr Connect (Bunker)
|
||||
pub const BUNKER_TIMEOUT: u64 = 30;
|
||||
|
||||
/// Total metadata requests will be grouped.
|
||||
pub const METADATA_BATCH_LIMIT: usize = 100;
|
||||
|
||||
@@ -44,9 +51,6 @@ pub const METADATA_BATCH_TIMEOUT: u64 = 300;
|
||||
/// Maximum timeout for waiting for finish (seconds)
|
||||
pub const WAIT_FOR_FINISH: u64 = 60;
|
||||
|
||||
/// Default width for all modals.
|
||||
pub const DEFAULT_MODAL_WIDTH: f32 = 420.;
|
||||
|
||||
/// Default width of the sidebar.
|
||||
pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -13,41 +12,109 @@ use crate::paths::support_dir;
|
||||
pub mod constants;
|
||||
pub mod paths;
|
||||
|
||||
/// Signals sent through the global event channel to notify UI components
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthReq {
|
||||
pub challenge: String,
|
||||
pub url: RelayUrl,
|
||||
}
|
||||
|
||||
impl AuthReq {
|
||||
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
|
||||
Self {
|
||||
challenge: challenge.into(),
|
||||
url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Notice {
|
||||
RelayFailed(RelayUrl),
|
||||
AuthFailed(RelayUrl),
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl Notice {
|
||||
pub fn as_str(&self) -> String {
|
||||
match self {
|
||||
Notice::AuthFailed(url) => format!("Authenticate failed for relay {url}"),
|
||||
Notice::RelayFailed(url) => format!("Failed to connect the relay {url}"),
|
||||
Notice::Custom(msg) => msg.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signals sent through the global event channel to notify UI
|
||||
#[derive(Debug)]
|
||||
pub enum NostrSignal {
|
||||
/// Signer has been set
|
||||
pub enum IngesterSignal {
|
||||
/// A signal to notify UI that the client's signer has been set
|
||||
SignerSet(PublicKey),
|
||||
|
||||
/// Signer has been unset
|
||||
/// A signal to notify UI that the client's signer has been unset
|
||||
SignerUnset,
|
||||
|
||||
/// Browser Signer Proxy service is not running
|
||||
/// A signal to notify UI that the relay requires authentication
|
||||
Auth(AuthReq),
|
||||
|
||||
/// A signal to notify UI that the browser proxy service is down
|
||||
ProxyDown,
|
||||
|
||||
/// Received a new metadata event from Relay Pool
|
||||
/// A signal to notify UI that a new metadata event has been received
|
||||
Metadata(Event),
|
||||
|
||||
/// Received a new gift wrap event from Relay Pool
|
||||
GiftWrap(Event),
|
||||
/// A signal to notify UI that a new gift wrap event has been received
|
||||
GiftWrap((EventId, Event)),
|
||||
|
||||
/// Finished processing all gift wrap events
|
||||
/// A signal to notify UI that all gift wrap events have been processed
|
||||
Finish,
|
||||
|
||||
/// Partially finished processing all gift wrap events
|
||||
/// A signal to notify UI that partial processing of gift wrap events has been completed
|
||||
PartialFinish,
|
||||
|
||||
/// DM relays have been found
|
||||
DmRelaysFound,
|
||||
/// A signal to notify UI that no DM relay for current user was found
|
||||
DmRelayNotFound,
|
||||
|
||||
/// Notice from Relay Pool
|
||||
Notice(String),
|
||||
/// A signal to notify UI that there are errors or notices occurred
|
||||
Notice(Notice),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Ingester {
|
||||
rx: Receiver<IngesterSignal>,
|
||||
tx: Sender<IngesterSignal>,
|
||||
}
|
||||
|
||||
impl Default for Ingester {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Ingester {
|
||||
pub fn new() -> Self {
|
||||
let (tx, rx) = smol::channel::bounded::<IngesterSignal>(2048);
|
||||
Self { rx, tx }
|
||||
}
|
||||
|
||||
pub fn signals(&self) -> &Receiver<IngesterSignal> {
|
||||
&self.rx
|
||||
}
|
||||
|
||||
pub async fn send(&self, signal: IngesterSignal) {
|
||||
if let Err(e) = self.tx.send(signal).await {
|
||||
log::error!("Failed to send signal: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static NOSTR_CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
static GLOBAL_CHANNEL: OnceLock<(Sender<NostrSignal>, Receiver<NostrSignal>)> = OnceLock::new();
|
||||
static PROCESSED_EVENTS: OnceLock<RwLock<BTreeSet<EventId>>> = OnceLock::new();
|
||||
|
||||
static INGESTER: OnceLock<Ingester> = OnceLock::new();
|
||||
|
||||
static SENT_IDS: OnceLock<RwLock<Vec<EventId>>> = OnceLock::new();
|
||||
|
||||
static CURRENT_TIMESTAMP: OnceLock<Timestamp> = OnceLock::new();
|
||||
|
||||
static FIRST_RUN: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
pub fn nostr_client() -> &'static Client {
|
||||
@@ -63,7 +130,7 @@ pub fn nostr_client() -> &'static Client {
|
||||
|
||||
let opts = ClientOptions::new()
|
||||
.gossip(true)
|
||||
.automatic_authentication(true)
|
||||
.automatic_authentication(false)
|
||||
.verify_subscriptions(false)
|
||||
// Sleep after idle for 30 seconds
|
||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||
@@ -74,21 +141,18 @@ pub fn nostr_client() -> &'static Client {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn global_channel() -> &'static (Sender<NostrSignal>, Receiver<NostrSignal>) {
|
||||
GLOBAL_CHANNEL.get_or_init(|| {
|
||||
let (sender, receiver) = smol::channel::bounded::<NostrSignal>(2048);
|
||||
(sender, receiver)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn processed_events() -> &'static RwLock<BTreeSet<EventId>> {
|
||||
PROCESSED_EVENTS.get_or_init(|| RwLock::new(BTreeSet::new()))
|
||||
pub fn ingester() -> &'static Ingester {
|
||||
INGESTER.get_or_init(Ingester::new)
|
||||
}
|
||||
|
||||
pub fn starting_time() -> &'static Timestamp {
|
||||
CURRENT_TIMESTAMP.get_or_init(Timestamp::now)
|
||||
}
|
||||
|
||||
pub fn sent_ids() -> &'static RwLock<Vec<EventId>> {
|
||||
SENT_IDS.get_or_init(|| RwLock::new(Vec::new()))
|
||||
}
|
||||
|
||||
pub fn first_run() -> &'static bool {
|
||||
FIRST_RUN.get_or_init(|| {
|
||||
let flag = support_dir().join(format!(".{}-first_run", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
[package]
|
||||
name = "identity"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ui = { path = "../ui" }
|
||||
global = { path = "../global" }
|
||||
common = { path = "../common" }
|
||||
client_keys = { path = "../client_keys" }
|
||||
settings = { path = "../settings" }
|
||||
signer_proxy = { path = "../signer_proxy" }
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
smol.workspace = true
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
webbrowser.workspace = true
|
||||
@@ -1,122 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use global::constants::ACCOUNT_IDENTIFIER;
|
||||
use global::{global_channel, nostr_client, NostrSignal};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Window};
|
||||
use nostr_connect::prelude::*;
|
||||
use signer_proxy::{BrowserSignerProxy, BrowserSignerProxyOptions};
|
||||
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) {
|
||||
Identity::set_global(cx.new(|cx| Identity::new(public_key, window, cx)), cx);
|
||||
}
|
||||
|
||||
struct GlobalIdentity(Entity<Identity>);
|
||||
|
||||
impl Global for GlobalIdentity {}
|
||||
|
||||
pub struct Identity {
|
||||
public_key: PublicKey,
|
||||
nip17_relays: Option<bool>,
|
||||
nip65_relays: Option<bool>,
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
/// Retrieve the Global Identity instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalIdentity>().0.clone()
|
||||
}
|
||||
|
||||
/// Retrieve the Identity instance
|
||||
pub fn read_global(cx: &App) -> &Self {
|
||||
cx.global::<GlobalIdentity>().0.read(cx)
|
||||
}
|
||||
|
||||
/// Check if the Global Identity instance has been set
|
||||
pub fn has_global(cx: &App) -> bool {
|
||||
cx.has_global::<GlobalIdentity>()
|
||||
}
|
||||
|
||||
/// Remove the Global Identity instance
|
||||
pub fn remove_global(cx: &mut App) {
|
||||
cx.remove_global::<GlobalIdentity>();
|
||||
}
|
||||
|
||||
/// Set the Global Identity instance
|
||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalIdentity(state));
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
public_key: PublicKey,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
nip17_relays: None,
|
||||
nip65_relays: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current identity's public key
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
}
|
||||
|
||||
/// Returns the current identity's NIP-17 relays status
|
||||
pub fn nip17_relays(&self) -> Option<bool> {
|
||||
self.nip17_relays
|
||||
}
|
||||
|
||||
/// Returns the current identity's NIP-65 relays status
|
||||
pub fn nip65_relays(&self) -> Option<bool> {
|
||||
self.nip65_relays
|
||||
}
|
||||
|
||||
/// Starts the browser proxy for nostr signer
|
||||
pub fn start_browser_proxy(cx: &App) {
|
||||
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
|
||||
let url = proxy.url();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let channel = global_channel();
|
||||
|
||||
if proxy.start().await.is_ok() {
|
||||
webbrowser::open(&url).ok();
|
||||
|
||||
loop {
|
||||
if proxy.is_session_active() {
|
||||
// Save the signer to disk for further logins
|
||||
if let Ok(public_key) = proxy.get_public_key().await {
|
||||
let keys = Keys::generate();
|
||||
let tags = vec![Tag::identifier(ACCOUNT_IDENTIFIER)];
|
||||
let kind = Kind::ApplicationSpecificData;
|
||||
|
||||
let builder = EventBuilder::new(kind, "extension")
|
||||
.tags(tags)
|
||||
.build(public_key)
|
||||
.sign(&keys)
|
||||
.await;
|
||||
|
||||
if let Ok(event) = builder {
|
||||
if let Err(e) = client.database().save_event(&event).await {
|
||||
log::error!("Failed to save event: {e}");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Set the client's signer with current proxy signer
|
||||
client.set_signer(proxy.clone()).await;
|
||||
|
||||
break;
|
||||
} else {
|
||||
channel.0.send(NostrSignal::ProxyDown).await.ok();
|
||||
}
|
||||
smol::Timer::after(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,9 @@ nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
chrono.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
fuzzy-matcher = "0.3.7"
|
||||
hashbrown = "0.15"
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use anyhow::Error;
|
||||
use common::event::EventUtils;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use global::nostr_client;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
|
||||
};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, WeakEntity, Window};
|
||||
use hashbrown::{HashMap, HashSet};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use room::RoomKind;
|
||||
@@ -29,7 +27,7 @@ struct GlobalRegistry(Entity<Registry>);
|
||||
impl Global for GlobalRegistry {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RegistrySignal {
|
||||
pub enum RegistryEvent {
|
||||
Open(WeakEntity<Room>),
|
||||
Close(u64),
|
||||
NewRequest(RoomKind),
|
||||
@@ -41,19 +39,21 @@ pub struct Registry {
|
||||
pub rooms: Vec<Entity<Room>>,
|
||||
|
||||
/// Collection of all persons (user profiles)
|
||||
pub persons: BTreeMap<PublicKey, Entity<Profile>>,
|
||||
pub persons: HashMap<PublicKey, Entity<Profile>>,
|
||||
|
||||
/// Indicates if rooms are currently being loaded
|
||||
///
|
||||
/// Always equal to `true` when the app starts
|
||||
pub loading: bool,
|
||||
|
||||
/// Subscriptions for observing changes
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 2]>,
|
||||
/// Public Key of the current user
|
||||
pub identity: Option<PublicKey>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl EventEmitter<RegistrySignal> for Registry {}
|
||||
impl EventEmitter<RegistryEvent> for Registry {}
|
||||
|
||||
impl Registry {
|
||||
/// Retrieve the Global Registry state
|
||||
@@ -73,74 +73,68 @@ impl Registry {
|
||||
|
||||
/// Create a new Registry instance
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
// Load all user profiles from the database when the Registry is created
|
||||
subscriptions.push(cx.observe_new::<Self>(|this, _window, cx| {
|
||||
let task = this.load_local_person(cx);
|
||||
this.set_persons_from_task(task, cx);
|
||||
}));
|
||||
let load_local_persons: Task<Result<Vec<Profile>, Error>> =
|
||||
cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let events = client.database().query(filter).await?;
|
||||
let mut profiles = vec![];
|
||||
|
||||
// When any Room is created, load members metadata
|
||||
subscriptions.push(cx.observe_new::<Room>(|this, _window, cx| {
|
||||
let state = Self::global(cx);
|
||||
let task = this.load_metadata(cx);
|
||||
for event in events.into_iter() {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
let profile = Profile::new(event.pubkey, metadata);
|
||||
profiles.push(profile);
|
||||
}
|
||||
|
||||
state.update(cx, |this, cx| {
|
||||
this.set_persons_from_task(task, cx);
|
||||
Ok(profiles)
|
||||
});
|
||||
}));
|
||||
|
||||
tasks.push(
|
||||
// Load all user profiles from the database when the Registry is created
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(profiles) = load_local_persons.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_persons(profiles, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
rooms: vec![],
|
||||
persons: BTreeMap::new(),
|
||||
persons: HashMap::new(),
|
||||
identity: None,
|
||||
loading: true,
|
||||
subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.rooms = vec![];
|
||||
self.loading = true;
|
||||
/// Returns the identity of the user.
|
||||
///
|
||||
/// WARNING: This method will panic if user is not logged in.
|
||||
pub fn identity(&self, cx: &App) -> Profile {
|
||||
self.get_person(&self.identity.unwrap(), cx)
|
||||
}
|
||||
|
||||
/// Sets the identity of the user.
|
||||
pub fn set_identity(&mut self, identity: PublicKey, cx: &mut Context<Self>) {
|
||||
self.identity = Some(identity);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_persons_from_task(
|
||||
&mut self,
|
||||
task: Task<Result<Vec<Profile>, Error>>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(profiles) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
for profile in profiles {
|
||||
this.persons
|
||||
.insert(profile.public_key(), cx.new(|_| profile));
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn load_local_person(&self, cx: &App) -> Task<Result<Vec<Profile>, Error>> {
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(100);
|
||||
let events = nostr_client().database().query(filter).await?;
|
||||
let mut profiles = vec![];
|
||||
|
||||
for event in events.into_iter() {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
let profile = Profile::new(event.pubkey, metadata);
|
||||
profiles.push(profile);
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
})
|
||||
/// Insert batch of persons
|
||||
pub fn set_persons(&mut self, profiles: Vec<Profile>, cx: &mut Context<Self>) {
|
||||
for profile in profiles.into_iter() {
|
||||
self.persons
|
||||
.insert(profile.public_key(), cx.new(|_| profile));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Get single person
|
||||
pub fn get_person(&self, public_key: &PublicKey, cx: &App) -> Profile {
|
||||
self.persons
|
||||
.get(public_key)
|
||||
@@ -149,6 +143,7 @@ impl Registry {
|
||||
.unwrap_or(Profile::new(public_key.to_owned(), Metadata::default()))
|
||||
}
|
||||
|
||||
/// Get group of persons
|
||||
pub fn get_group_person(&self, public_keys: &[PublicKey], cx: &App) -> Vec<Profile> {
|
||||
let mut profiles = vec![];
|
||||
|
||||
@@ -160,6 +155,7 @@ impl Registry {
|
||||
profiles
|
||||
}
|
||||
|
||||
/// Insert or update a person
|
||||
pub fn insert_or_update_person(&mut self, event: Event, cx: &mut App) {
|
||||
let public_key = event.pubkey;
|
||||
let Ok(metadata) = Metadata::from_json(event.content) else {
|
||||
@@ -213,7 +209,7 @@ impl Registry {
|
||||
/// Close a room.
|
||||
pub fn close_room(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
if self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||
cx.emit(RegistrySignal::Close(id));
|
||||
cx.emit(RegistryEvent::Close(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +249,14 @@ impl Registry {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Reset the registry.
|
||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.rooms = vec![];
|
||||
self.loading = true;
|
||||
self.identity = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Load all rooms from the database.
|
||||
pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
log::info!("Starting to load chat rooms...");
|
||||
@@ -260,7 +264,7 @@ impl Registry {
|
||||
// Get the contact bypass setting
|
||||
let contact_bypass = AppSettings::get_contact_bypass(cx);
|
||||
|
||||
let task: Task<Result<BTreeSet<Room>, Error>> = cx.background_spawn(async move {
|
||||
let task: Task<Result<HashSet<Room>, Error>> = cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
@@ -279,7 +283,7 @@ impl Registry {
|
||||
let recv_events = client.database().query(recv).await?;
|
||||
let events = send_events.merge(recv_events);
|
||||
|
||||
let mut rooms: BTreeSet<Room> = BTreeSet::new();
|
||||
let mut rooms: HashSet<Room> = HashSet::new();
|
||||
|
||||
// Process each event and group by room hash
|
||||
for event in events
|
||||
@@ -343,7 +347,7 @@ impl Registry {
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn extend_rooms(&mut self, rooms: BTreeSet<Room>, cx: &mut Context<Self>) {
|
||||
pub(crate) fn extend_rooms(&mut self, rooms: HashSet<Room>, cx: &mut Context<Self>) {
|
||||
let mut room_map: HashMap<u64, usize> = HashMap::with_capacity(self.rooms.len());
|
||||
|
||||
for (index, room) in self.rooms.iter().enumerate() {
|
||||
@@ -380,7 +384,7 @@ impl Registry {
|
||||
weak_room
|
||||
};
|
||||
|
||||
cx.emit(RegistrySignal::Open(weak_room));
|
||||
cx.emit(RegistryEvent::Open(weak_room));
|
||||
}
|
||||
|
||||
/// Refresh messages for a room in the global registry
|
||||
@@ -400,7 +404,7 @@ impl Registry {
|
||||
/// Updates room ordering based on the most recent messages.
|
||||
pub fn event_to_message(
|
||||
&mut self,
|
||||
identity: PublicKey,
|
||||
gift_wrap_id: EventId,
|
||||
event: Event,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -408,6 +412,10 @@ impl Registry {
|
||||
let id = event.uniq_id();
|
||||
let author = event.pubkey;
|
||||
|
||||
let Some(identity) = self.identity else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
|
||||
// Update room
|
||||
room.update(cx, |this, cx| {
|
||||
@@ -420,7 +428,7 @@ impl Registry {
|
||||
|
||||
// Emit the new message to the room
|
||||
cx.defer_in(window, move |this, _window, cx| {
|
||||
this.emit_message(event, cx);
|
||||
this.emit_message(gift_wrap_id, event, cx);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -436,7 +444,7 @@ impl Registry {
|
||||
|
||||
// Notify the UI about the new room
|
||||
cx.defer_in(window, move |_this, _window, cx| {
|
||||
cx.emit(RegistrySignal::NewRequest(RoomKind::default()));
|
||||
cx.emit(RegistryEvent::NewRequest(RoomKind::default()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use std::hash::Hash;
|
||||
|
||||
use chrono::{Local, TimeZone};
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -10,8 +8,8 @@ pub struct RenderedMessage {
|
||||
/// Author's public key
|
||||
pub author: PublicKey,
|
||||
/// The content/text of the message
|
||||
pub content: SharedString,
|
||||
/// When the message was created
|
||||
pub content: String,
|
||||
/// Message created time as unix timestamp
|
||||
pub created_at: Timestamp,
|
||||
/// List of mentioned public keys in the message
|
||||
pub mentions: Vec<PublicKey>,
|
||||
@@ -27,7 +25,7 @@ impl From<Event> for RenderedMessage {
|
||||
Self {
|
||||
id: inner.id,
|
||||
author: inner.pubkey,
|
||||
content: inner.content.into(),
|
||||
content: inner.content,
|
||||
created_at: inner.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
@@ -44,7 +42,7 @@ impl From<UnsignedEvent> for RenderedMessage {
|
||||
// Event ID must be known
|
||||
id: inner.id.unwrap(),
|
||||
author: inner.pubkey,
|
||||
content: inner.content.into(),
|
||||
content: inner.content,
|
||||
created_at: inner.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
@@ -90,30 +88,6 @@ impl Hash for RenderedMessage {
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderedMessage {
|
||||
/// Returns a human-readable string representing how long ago the message was created
|
||||
pub fn ago(&self) -> SharedString {
|
||||
let input_time = match Local.timestamp_opt(self.created_at.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "Invalid timestamp".into(),
|
||||
};
|
||||
|
||||
let now = Local::now();
|
||||
let input_date = input_time.date_naive();
|
||||
let now_date = now.date_naive();
|
||||
let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
|
||||
|
||||
let time_format = input_time.format("%H:%M %p");
|
||||
|
||||
match input_date {
|
||||
date if date == now_date => format!("Today at {time_format}"),
|
||||
date if date == yesterday_date => format!("Yesterday at {time_format}"),
|
||||
_ => format!("{}, {time_format}", input_time.format("%d/%m/%y")),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_mentions(content: &str) -> Vec<PublicKey> {
|
||||
let parser = NostrParser::new();
|
||||
let tokens = parser.parse(content);
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use chrono::{Local, TimeZone};
|
||||
use common::display::DisplayProfile;
|
||||
use anyhow::Error;
|
||||
use common::display::ReadableProfile;
|
||||
use common::event::EventUtils;
|
||||
use global::nostr_client;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::Registry;
|
||||
|
||||
pub(crate) const NOW: &str = "now";
|
||||
pub(crate) const SECONDS_IN_MINUTE: i64 = 60;
|
||||
pub(crate) const MINUTES_IN_HOUR: i64 = 60;
|
||||
pub(crate) const HOURS_IN_DAY: i64 = 24;
|
||||
pub(crate) const DAYS_IN_MONTH: i64 = 30;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SendReport {
|
||||
pub receiver: PublicKey,
|
||||
@@ -69,7 +62,7 @@ impl SendReport {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RoomSignal {
|
||||
NewMessage(Box<Event>),
|
||||
NewMessage((EventId, Box<Event>)),
|
||||
Refresh,
|
||||
}
|
||||
|
||||
@@ -85,11 +78,11 @@ pub struct Room {
|
||||
pub id: u64,
|
||||
pub created_at: Timestamp,
|
||||
/// Subject of the room
|
||||
pub subject: Option<SharedString>,
|
||||
pub subject: Option<String>,
|
||||
/// Picture of the room
|
||||
pub picture: Option<SharedString>,
|
||||
pub picture: Option<String>,
|
||||
/// All members of the room
|
||||
pub members: SmallVec<[PublicKey; 2]>,
|
||||
pub members: Vec<PublicKey>,
|
||||
/// Kind
|
||||
pub kind: RoomKind,
|
||||
}
|
||||
@@ -112,6 +105,12 @@ impl PartialEq for Room {
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for Room {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Room {}
|
||||
|
||||
impl EventEmitter<RoomSignal> for Room {}
|
||||
@@ -120,21 +119,25 @@ impl Room {
|
||||
pub fn new(event: &Event) -> Self {
|
||||
let id = event.uniq_id();
|
||||
let created_at = event.created_at;
|
||||
let public_keys = event.all_pubkeys();
|
||||
|
||||
// Convert pubkeys into members
|
||||
let members = public_keys.into_iter().unique().sorted().collect();
|
||||
// Get the members from the event's tags and event's pubkey
|
||||
let members = event
|
||||
.all_pubkeys()
|
||||
.into_iter()
|
||||
.unique()
|
||||
.sorted()
|
||||
.collect_vec();
|
||||
|
||||
// Get the subject from the event's tags
|
||||
let subject = if let Some(tag) = event.tags.find(TagKind::Subject) {
|
||||
tag.content().map(|s| s.to_owned().into())
|
||||
tag.content().map(|s| s.to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get the picture from the event's tags
|
||||
let picture = if let Some(tag) = event.tags.find(TagKind::custom("picture")) {
|
||||
tag.content().map(|s| s.to_owned().into())
|
||||
tag.content().map(|s| s.to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -177,11 +180,9 @@ impl Room {
|
||||
///
|
||||
/// The modified Room instance with the new member list after rearrangement
|
||||
pub fn rearrange_by(mut self, rearrange_by: PublicKey) -> Self {
|
||||
let (not_match, matches): (Vec<PublicKey>, Vec<PublicKey>) = self
|
||||
.members
|
||||
.into_iter()
|
||||
.partition(|key| key != &rearrange_by);
|
||||
self.members = not_match.into();
|
||||
let (not_match, matches): (Vec<PublicKey>, Vec<PublicKey>) =
|
||||
self.members.iter().partition(|&key| key != &rearrange_by);
|
||||
self.members = not_match;
|
||||
self.members.extend(matches);
|
||||
self
|
||||
}
|
||||
@@ -224,8 +225,8 @@ impl Room {
|
||||
///
|
||||
/// * `subject` - The new subject to set
|
||||
/// * `cx` - The context to notify about the update
|
||||
pub fn subject(&mut self, subject: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||
self.subject = Some(subject.into());
|
||||
pub fn subject(&mut self, subject: String, cx: &mut Context<Self>) {
|
||||
self.subject = Some(subject);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -235,42 +236,11 @@ impl Room {
|
||||
///
|
||||
/// * `picture` - The new subject to set
|
||||
/// * `cx` - The context to notify about the update
|
||||
pub fn picture(&mut self, picture: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||
self.picture = Some(picture.into());
|
||||
pub fn picture(&mut self, picture: String, cx: &mut Context<Self>) {
|
||||
self.picture = Some(picture);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Returns a human-readable string representing how long ago the room was created
|
||||
///
|
||||
/// The string will be formatted differently based on the time elapsed:
|
||||
/// - Less than a minute: "now"
|
||||
/// - Less than an hour: "Xm" (minutes)
|
||||
/// - Less than a day: "Xh" (hours)
|
||||
/// - Less than a month: "Xd" (days)
|
||||
/// - More than a month: "MMM DD" (month abbreviation and day)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A SharedString containing the formatted time representation
|
||||
pub fn ago(&self) -> SharedString {
|
||||
let input_time = match Local.timestamp_opt(self.created_at.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "1m".into(),
|
||||
};
|
||||
|
||||
let now = Local::now();
|
||||
let duration = now.signed_duration_since(input_time);
|
||||
|
||||
match duration {
|
||||
d if d.num_seconds() < SECONDS_IN_MINUTE => NOW.into(),
|
||||
d if d.num_minutes() < MINUTES_IN_HOUR => format!("{}m", d.num_minutes()),
|
||||
d if d.num_hours() < HOURS_IN_DAY => format!("{}h", d.num_hours()),
|
||||
d if d.num_days() < DAYS_IN_MONTH => format!("{}d", d.num_days()),
|
||||
_ => input_time.format("%b %d").to_string(),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Gets the display name for the room
|
||||
///
|
||||
/// If the room has a subject set, that will be used as the display name.
|
||||
@@ -282,8 +252,8 @@ impl Room {
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A SharedString containing the display name
|
||||
pub fn display_name(&self, cx: &App) -> SharedString {
|
||||
/// A string containing the display name
|
||||
pub fn display_name(&self, cx: &App) -> String {
|
||||
if let Some(subject) = self.subject.clone() {
|
||||
subject
|
||||
} else {
|
||||
@@ -305,8 +275,8 @@ impl Room {
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A SharedString containing the image path or URL
|
||||
pub fn display_image(&self, proxy: bool, cx: &App) -> SharedString {
|
||||
/// A string containing the image path or URL
|
||||
pub fn display_image(&self, proxy: bool, cx: &App) -> String {
|
||||
if let Some(picture) = self.picture.as_ref() {
|
||||
picture.clone()
|
||||
} else if !self.is_group() {
|
||||
@@ -325,7 +295,7 @@ impl Room {
|
||||
}
|
||||
|
||||
/// Merge the names of the first two members of the room.
|
||||
pub(crate) fn merge_name(&self, cx: &App) -> SharedString {
|
||||
pub(crate) fn merge_name(&self, cx: &App) -> String {
|
||||
let registry = Registry::read_global(cx);
|
||||
|
||||
if self.is_group() {
|
||||
@@ -346,37 +316,12 @@ impl Room {
|
||||
name = format!("{}, +{}", name, profiles.len() - 2);
|
||||
}
|
||||
|
||||
name.into()
|
||||
name
|
||||
} else {
|
||||
self.first_member(cx).display_name()
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads all profiles for this room members from the database
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A Task that resolves to Result<Vec<Profile>, Error> containing all profiles for this room
|
||||
pub fn load_metadata(&self, cx: &mut Context<Self>) -> Task<Result<Vec<Profile>, Error>> {
|
||||
let public_keys = self.members.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let database = nostr_client().database();
|
||||
let mut profiles = vec![];
|
||||
|
||||
for public_key in public_keys.into_iter() {
|
||||
let metadata = database.metadata(public_key).await?.unwrap_or_default();
|
||||
profiles.push(Profile::new(public_key, metadata));
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
})
|
||||
}
|
||||
|
||||
/// Loads all messages for this room from the database
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -397,22 +342,21 @@ impl Room {
|
||||
.authors(members.clone())
|
||||
.pubkeys(members.clone());
|
||||
|
||||
let events = client
|
||||
let events: Vec<Event> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.sorted_by_key(|ev| ev.created_at)
|
||||
.filter(|ev| ev.compare_pubkeys(&members))
|
||||
.collect::<Vec<_>>();
|
||||
.collect();
|
||||
|
||||
Ok(events)
|
||||
})
|
||||
}
|
||||
|
||||
/// Emits a new message signal to the current room
|
||||
pub fn emit_message(&self, event: Event, cx: &mut Context<Self>) {
|
||||
cx.emit(RoomSignal::NewMessage(Box::new(event)));
|
||||
pub fn emit_message(&self, gift_wrap_id: EventId, event: Event, cx: &mut Context<Self>) {
|
||||
cx.emit(RoomSignal::NewMessage((gift_wrap_id, Box::new(event))));
|
||||
}
|
||||
|
||||
/// Emits a signal to refresh the current room's messages.
|
||||
@@ -473,7 +417,7 @@ impl Room {
|
||||
let content = content.to_owned();
|
||||
let subject = self.subject.clone();
|
||||
let picture = self.picture.clone();
|
||||
let public_keys = self.members.clone();
|
||||
let mut public_keys = self.members.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
@@ -516,26 +460,25 @@ impl Room {
|
||||
tags.push(Tag::custom(TagKind::custom("picture"), vec![picture]));
|
||||
}
|
||||
|
||||
let Some((current_user, receivers)) = public_keys.split_last() else {
|
||||
return Err(anyhow!("Something is wrong. Cannot get receivers list."));
|
||||
};
|
||||
// Remove the current public key from the list of receivers
|
||||
public_keys.retain(|&pk| pk != public_key);
|
||||
|
||||
// Stored all send errors
|
||||
let mut reports = vec![];
|
||||
|
||||
for receiver in receivers.iter() {
|
||||
for receiver in public_keys.into_iter() {
|
||||
match client
|
||||
.send_private_msg(*receiver, &content, tags.clone())
|
||||
.send_private_msg(receiver, &content, tags.clone())
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
reports.push(SendReport::output(*receiver, output));
|
||||
reports.push(SendReport::output(receiver, output));
|
||||
}
|
||||
Err(e) => {
|
||||
if let nostr_sdk::client::Error::PrivateMsgRelaysNotFound = e {
|
||||
reports.push(SendReport::nip17_relays_not_found(*receiver));
|
||||
reports.push(SendReport::nip17_relays_not_found(receiver));
|
||||
} else {
|
||||
reports.push(SendReport::error(*receiver, e.to_string()));
|
||||
reports.push(SendReport::error(receiver, e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -544,17 +487,17 @@ impl Room {
|
||||
// Only send a backup message to current user if sent successfully to others
|
||||
if reports.iter().all(|r| r.is_sent_success()) && backup {
|
||||
match client
|
||||
.send_private_msg(*current_user, &content, tags.clone())
|
||||
.send_private_msg(public_key, &content, tags.clone())
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
reports.push(SendReport::output(*current_user, output));
|
||||
reports.push(SendReport::output(public_key, output));
|
||||
}
|
||||
Err(e) => {
|
||||
if let nostr_sdk::client::Error::PrivateMsgRelaysNotFound = e {
|
||||
reports.push(SendReport::nip17_relays_not_found(*current_user));
|
||||
reports.push(SendReport::nip17_relays_not_found(public_key));
|
||||
} else {
|
||||
reports.push(SendReport::error(*current_user, e.to_string()));
|
||||
reports.push(SendReport::error(public_key, e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use std::any::TypeId;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
blue, div, green, px, red, yellow, Animation, AnimationExt, App, AppContext, ClickEvent,
|
||||
Context, DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render, SharedString, StatefulInteractiveElement, Styled, Subscription,
|
||||
Window,
|
||||
div, px, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context,
|
||||
DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
|
||||
Subscription, Window,
|
||||
};
|
||||
use smol::Timer;
|
||||
use theme::ActiveTheme;
|
||||
@@ -18,13 +18,30 @@ use crate::animation::cubic_bezier;
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::{h_flex, v_flex, Icon, IconName, Sizable as _, StyledExt};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub enum NotificationType {
|
||||
#[default]
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl NotificationType {
|
||||
fn icon(&self, cx: &App) -> Icon {
|
||||
match self {
|
||||
Self::Info => Icon::new(IconName::Info).text_color(cx.theme().element_active),
|
||||
Self::Warning => Icon::new(IconName::Report).text_color(cx.theme().warning_foreground),
|
||||
Self::Success => {
|
||||
Icon::new(IconName::CheckCircle).text_color(cx.theme().element_foreground)
|
||||
}
|
||||
Self::Error => {
|
||||
Icon::new(IconName::CloseCircle).text_color(cx.theme().danger_foreground)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Hash, Eq)]
|
||||
pub(crate) enum NotificationId {
|
||||
Id(TypeId),
|
||||
@@ -43,8 +60,6 @@ impl From<(TypeId, ElementId)> for NotificationId {
|
||||
}
|
||||
}
|
||||
|
||||
type OnClick = Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>;
|
||||
|
||||
/// A notification element.
|
||||
pub struct Notification {
|
||||
/// The id is used make the notification unique.
|
||||
@@ -52,48 +67,54 @@ pub struct Notification {
|
||||
///
|
||||
/// None means the notification will be added to the end of the list.
|
||||
id: NotificationId,
|
||||
kind: NotificationType,
|
||||
style: StyleRefinement,
|
||||
type_: Option<NotificationType>,
|
||||
title: Option<SharedString>,
|
||||
message: SharedString,
|
||||
message: Option<SharedString>,
|
||||
icon: Option<Icon>,
|
||||
autohide: bool,
|
||||
on_click: OnClick,
|
||||
#[allow(clippy::type_complexity)]
|
||||
action_builder: Option<Rc<dyn Fn(&mut Window, &mut Context<Self>) -> Button>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
content_builder: Option<Rc<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
closing: bool,
|
||||
}
|
||||
|
||||
impl From<String> for Notification {
|
||||
fn from(s: String) -> Self {
|
||||
Self::new(s)
|
||||
Self::new().message(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Cow<'static, str>> for Notification {
|
||||
fn from(s: Cow<'static, str>) -> Self {
|
||||
Self::new(s)
|
||||
Self::new().message(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SharedString> for Notification {
|
||||
fn from(s: SharedString) -> Self {
|
||||
Self::new(s)
|
||||
Self::new().message(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&'static str> for Notification {
|
||||
fn from(s: &'static str) -> Self {
|
||||
Self::new(s)
|
||||
Self::new().message(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(NotificationType, &'static str)> for Notification {
|
||||
fn from((type_, content): (NotificationType, &'static str)) -> Self {
|
||||
Self::new(content).with_type(type_)
|
||||
Self::new().message(content).with_type(type_)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(NotificationType, SharedString)> for Notification {
|
||||
fn from((type_, content): (NotificationType, SharedString)) -> Self {
|
||||
Self::new(content).with_type(type_)
|
||||
Self::new().message(content).with_type(type_)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,36 +124,52 @@ impl Notification {
|
||||
/// Create a new notification with the given content.
|
||||
///
|
||||
/// default width is 320px.
|
||||
pub fn new(message: impl Into<SharedString>) -> Self {
|
||||
pub fn new() -> Self {
|
||||
let id: SharedString = uuid::Uuid::new_v4().to_string().into();
|
||||
let id = (TypeId::of::<DefaultIdType>(), id.into());
|
||||
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: StyleRefinement::default(),
|
||||
title: None,
|
||||
message: message.into(),
|
||||
kind: NotificationType::Info,
|
||||
message: None,
|
||||
type_: None,
|
||||
icon: None,
|
||||
autohide: true,
|
||||
action_builder: None,
|
||||
content_builder: None,
|
||||
on_click: None,
|
||||
closing: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(mut self, message: impl Into<SharedString>) -> Self {
|
||||
self.message = Some(message.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn info(message: impl Into<SharedString>) -> Self {
|
||||
Self::new(message).with_type(NotificationType::Info)
|
||||
Self::new()
|
||||
.message(message)
|
||||
.with_type(NotificationType::Info)
|
||||
}
|
||||
|
||||
pub fn success(message: impl Into<SharedString>) -> Self {
|
||||
Self::new(message).with_type(NotificationType::Success)
|
||||
Self::new()
|
||||
.message(message)
|
||||
.with_type(NotificationType::Success)
|
||||
}
|
||||
|
||||
pub fn warning(message: impl Into<SharedString>) -> Self {
|
||||
Self::new(message).with_type(NotificationType::Warning)
|
||||
Self::new()
|
||||
.message(message)
|
||||
.with_type(NotificationType::Warning)
|
||||
}
|
||||
|
||||
pub fn error(message: impl Into<SharedString>) -> Self {
|
||||
Self::new(message).with_type(NotificationType::Error)
|
||||
Self::new()
|
||||
.message(message)
|
||||
.with_type(NotificationType::Error)
|
||||
}
|
||||
|
||||
/// Set the type for unique identification of the notification.
|
||||
@@ -147,8 +184,8 @@ impl Notification {
|
||||
}
|
||||
|
||||
/// Set the type and id of the notification, used to uniquely identify the notification.
|
||||
pub fn id1<T: Sized + 'static>(mut self, key: impl Into<ElementId>) -> Self {
|
||||
self.id = (TypeId::of::<T>(), key.into()).into();
|
||||
pub fn custom_id(mut self, key: impl Into<ElementId>) -> Self {
|
||||
self.id = (TypeId::of::<DefaultIdType>(), key.into()).into();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -170,7 +207,7 @@ impl Notification {
|
||||
|
||||
/// Set the type of the notification, default is NotificationType::Info.
|
||||
pub fn with_type(mut self, type_: NotificationType) -> Self {
|
||||
self.kind = type_;
|
||||
self.type_ = Some(type_);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -185,11 +222,21 @@ impl Notification {
|
||||
mut self,
|
||||
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Arc::new(on_click));
|
||||
self.on_click = Some(Rc::new(on_click));
|
||||
self
|
||||
}
|
||||
|
||||
fn dismiss(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
/// Set the action button of the notification.
|
||||
pub fn action<F>(mut self, action: F) -> Self
|
||||
where
|
||||
F: Fn(&mut Window, &mut Context<Self>) -> Button + 'static,
|
||||
{
|
||||
self.action_builder = Some(Rc::new(action));
|
||||
self
|
||||
}
|
||||
|
||||
/// Dismiss the notification.
|
||||
pub fn dismiss(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.closing = true;
|
||||
cx.notify();
|
||||
|
||||
@@ -207,31 +254,48 @@ impl Notification {
|
||||
})
|
||||
.detach()
|
||||
}
|
||||
|
||||
/// Set the content of the notification.
|
||||
pub fn content(
|
||||
mut self,
|
||||
content: impl Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static,
|
||||
) -> Self {
|
||||
self.content_builder = Some(Rc::new(content));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Notification {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for Notification {}
|
||||
|
||||
impl FluentBuilder for Notification {}
|
||||
|
||||
impl Styled for Notification {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Notification {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let closing = self.closing;
|
||||
let icon = match self.icon.clone() {
|
||||
Some(icon) => icon,
|
||||
None => match self.kind {
|
||||
NotificationType::Info => Icon::new(IconName::Info).text_color(blue()),
|
||||
NotificationType::Warning => Icon::new(IconName::Info).text_color(yellow()),
|
||||
NotificationType::Error => Icon::new(IconName::CloseCircle).text_color(red()),
|
||||
NotificationType::Success => Icon::new(IconName::CheckCircle).text_color(green()),
|
||||
},
|
||||
let icon = match self.type_ {
|
||||
None => self.icon.clone(),
|
||||
Some(type_) => Some(type_.icon(cx)),
|
||||
};
|
||||
|
||||
div()
|
||||
h_flex()
|
||||
.id("notification")
|
||||
.refine_style(&self.style)
|
||||
.group("")
|
||||
.occlude()
|
||||
.relative()
|
||||
.w_72()
|
||||
.w_96()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().surface_background)
|
||||
@@ -239,52 +303,70 @@ impl Render for Notification {
|
||||
.shadow_md()
|
||||
.p_2()
|
||||
.gap_3()
|
||||
.child(div().absolute().top_2p5().left_2().child(icon))
|
||||
.justify_start()
|
||||
.items_start()
|
||||
.when_some(icon, |this, icon| {
|
||||
this.child(div().flex_shrink_0().pt_1().child(icon))
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.pl_6()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.when_some(self.title.clone(), |this, title| {
|
||||
this.child(div().text_xs().font_semibold().child(title))
|
||||
})
|
||||
.overflow_hidden()
|
||||
.child(div().text_xs().child(self.message.clone())),
|
||||
.when_some(self.title.clone(), |this, title| {
|
||||
this.child(div().text_sm().font_semibold().child(title))
|
||||
})
|
||||
.when_some(self.message.clone(), |this, message| {
|
||||
this.child(div().text_sm().child(message))
|
||||
})
|
||||
.when_some(self.content_builder.clone(), |this, child_builder| {
|
||||
this.child(child_builder(window, cx))
|
||||
})
|
||||
.when_some(self.action_builder.clone(), |this, action_builder| {
|
||||
this.child(action_builder(window, cx).small().w_full().my_2())
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_2p5()
|
||||
.right_2p5()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.child(
|
||||
Button::new("close")
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dismiss(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.when_some(self.on_click.clone(), |this, on_click| {
|
||||
this.cursor_pointer()
|
||||
.on_click(cx.listener(move |view, event, window, cx| {
|
||||
view.dismiss(event, window, cx);
|
||||
on_click(event, window, cx);
|
||||
}))
|
||||
})
|
||||
.when(!self.autohide, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.absolute()
|
||||
.top_1()
|
||||
.right_1()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.child(
|
||||
Button::new("close")
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(Self::dismiss)),
|
||||
),
|
||||
)
|
||||
this.on_click(cx.listener(move |view, event, window, cx| {
|
||||
view.dismiss(window, cx);
|
||||
on_click(event, window, cx);
|
||||
}))
|
||||
})
|
||||
.with_animation(
|
||||
ElementId::NamedInteger("slide-down".into(), closing as u64),
|
||||
Animation::new(Duration::from_secs_f64(0.15))
|
||||
Animation::new(Duration::from_secs_f64(0.25))
|
||||
.with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
|
||||
move |this, delta| {
|
||||
if closing {
|
||||
let x_offset = px(0.) + delta * px(45.);
|
||||
this.left(px(0.) + x_offset).opacity(1. - delta)
|
||||
let opacity = 1. - delta;
|
||||
this.left(px(0.) + x_offset)
|
||||
.shadow_none()
|
||||
.opacity(opacity)
|
||||
.when(opacity < 0.85, |this| this.shadow_none())
|
||||
} else {
|
||||
let y_offset = px(-45.) + delta * px(45.);
|
||||
this.top(px(0.) + y_offset).opacity(delta)
|
||||
let opacity = delta;
|
||||
this.top(px(0.) + y_offset)
|
||||
.opacity(opacity)
|
||||
.when(opacity < 0.85, |this| this.shadow_none())
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -296,7 +378,7 @@ pub struct NotificationList {
|
||||
/// Notifications that will be auto hidden.
|
||||
pub(crate) notifications: VecDeque<Entity<Notification>>,
|
||||
expanded: bool,
|
||||
subscriptions: HashMap<NotificationId, Subscription>,
|
||||
_subscriptions: HashMap<NotificationId, Subscription>,
|
||||
}
|
||||
|
||||
impl NotificationList {
|
||||
@@ -304,16 +386,14 @@ impl NotificationList {
|
||||
Self {
|
||||
notifications: VecDeque::new(),
|
||||
expanded: false,
|
||||
subscriptions: HashMap::new(),
|
||||
_subscriptions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(
|
||||
&mut self,
|
||||
notification: impl Into<Notification>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
pub fn push<T>(&mut self, notification: T, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<Notification>,
|
||||
{
|
||||
let notification = notification.into();
|
||||
let id = notification.id.clone();
|
||||
let autohide = notification.autohide;
|
||||
@@ -323,28 +403,47 @@ impl NotificationList {
|
||||
|
||||
let notification = cx.new(|_| notification);
|
||||
|
||||
self.subscriptions.insert(
|
||||
self._subscriptions.insert(
|
||||
id.clone(),
|
||||
cx.subscribe(¬ification, move |view, _, _: &DismissEvent, cx| {
|
||||
view.notifications.retain(|note| id != note.read(cx).id);
|
||||
view.subscriptions.remove(&id);
|
||||
view._subscriptions.remove(&id);
|
||||
}),
|
||||
);
|
||||
|
||||
self.notifications.push_back(notification.clone());
|
||||
|
||||
if autohide {
|
||||
// Sleep for 3 seconds to autohide the notification
|
||||
// Sleep for 5 seconds to autohide the notification
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
Timer::after(Duration::from_secs(3)).await;
|
||||
_ = notification.update_in(cx, |note, window, cx| {
|
||||
note.dismiss(&ClickEvent::default(), window, cx)
|
||||
});
|
||||
Timer::after(Duration::from_secs(5)).await;
|
||||
|
||||
if let Err(error) =
|
||||
notification.update_in(cx, |note, window, cx| note.dismiss(window, cx))
|
||||
{
|
||||
log::error!("Failed to auto hide notification: {error}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn close<T>(&mut self, key: T, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<ElementId>,
|
||||
{
|
||||
let id = (TypeId::of::<DefaultIdType>(), key.into()).into();
|
||||
|
||||
if let Some(n) = self.notifications.iter().find(|n| n.read(cx).id == id) {
|
||||
n.update(cx, |note, cx| {
|
||||
note.dismiss(window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn clear(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.notifications.clear();
|
||||
cx.notify();
|
||||
@@ -356,24 +455,25 @@ impl NotificationList {
|
||||
}
|
||||
|
||||
impl Render for NotificationList {
|
||||
fn render(
|
||||
&mut self,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let size = window.viewport_size();
|
||||
let items = self.notifications.iter().rev().take(10).rev().cloned();
|
||||
|
||||
div().absolute().top_4().right_4().child(
|
||||
v_flex()
|
||||
.id("notification-list")
|
||||
.h(size.height - px(8.))
|
||||
.on_hover(cx.listener(|view, hovered, _, cx| {
|
||||
view.expanded = *hovered;
|
||||
cx.notify()
|
||||
}))
|
||||
.gap_3()
|
||||
.children(items),
|
||||
)
|
||||
div()
|
||||
.id("notification-wrapper")
|
||||
.absolute()
|
||||
.top_4()
|
||||
.right_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.id("notification-list")
|
||||
.h(size.height - px(8.))
|
||||
.gap_3()
|
||||
.children(items)
|
||||
.on_hover(cx.listener(|view, hovered, _, cx| {
|
||||
view.expanded = *hovered;
|
||||
cx.notify()
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
actions, anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, Bounds, Context,
|
||||
Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
||||
IntoElement, KeyBinding, Keystroke, ParentElement, Pixels, Render, ScrollHandle, SharedString,
|
||||
StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window,
|
||||
actions, anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, AsKeystroke,
|
||||
Bounds, Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, KeyBinding, Keystroke, ParentElement, Pixels, Render,
|
||||
ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity,
|
||||
Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
@@ -472,7 +473,7 @@ impl PopupMenu {
|
||||
keybinding
|
||||
.keystrokes()
|
||||
.iter()
|
||||
.map(|key| key_shortcut(key.clone())),
|
||||
.map(|key| key_shortcut(key.as_keystroke().clone())),
|
||||
);
|
||||
|
||||
return Some(el);
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::rc::Rc;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, AnyView, App, AppContext, Context, Decorations, Entity, FocusHandle, InteractiveElement,
|
||||
IntoElement, ParentElement as _, Render, Styled, Window,
|
||||
IntoElement, ParentElement as _, Render, SharedString, Styled, Window,
|
||||
};
|
||||
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING};
|
||||
|
||||
@@ -34,6 +34,9 @@ pub trait ContextModal: Sized {
|
||||
/// Pushes a notification to the notification list.
|
||||
fn push_notification(&mut self, note: impl Into<Notification>, cx: &mut App);
|
||||
|
||||
/// Clears a notification by its ID.
|
||||
fn clear_notification_by_id(&mut self, id: SharedString, cx: &mut App);
|
||||
|
||||
/// Clear all notifications
|
||||
fn clear_notifications(&mut self, cx: &mut App);
|
||||
|
||||
@@ -112,6 +115,15 @@ impl ContextModal for Window {
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_notification_by_id(&mut self, id: SharedString, cx: &mut App) {
|
||||
Root::update(self, cx, move |root, window, cx| {
|
||||
root.notification.update(cx, |view, cx| {
|
||||
view.close(id.clone(), window, cx);
|
||||
});
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn notifications(&mut self, cx: &mut App) -> Rc<Vec<Entity<Notification>>> {
|
||||
let entity = Root::read(self, cx).notification.clone();
|
||||
Rc::new(entity.read(cx).notifications())
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{
|
||||
bounce, div, ease_in_out, Animation, AnimationExt, Div, IntoElement, ParentElement as _,
|
||||
RenderOnce, Styled,
|
||||
bounce, div, ease_in_out, Animation, AnimationExt, IntoElement, RenderOnce, StyleRefinement,
|
||||
Styled,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::StyledExt;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Skeleton {
|
||||
base: Div,
|
||||
style: StyleRefinement,
|
||||
secondary: bool,
|
||||
}
|
||||
|
||||
impl Skeleton {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base: div().w_full().h_4().rounded_md(),
|
||||
style: StyleRefinement::default(),
|
||||
secondary: false,
|
||||
}
|
||||
}
|
||||
@@ -34,7 +36,7 @@ impl Default for Skeleton {
|
||||
|
||||
impl Styled for Skeleton {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +48,13 @@ impl RenderOnce for Skeleton {
|
||||
cx.theme().ghost_element_active
|
||||
};
|
||||
|
||||
div().child(
|
||||
self.base.bg(color).with_animation(
|
||||
div()
|
||||
.w_full()
|
||||
.h_4()
|
||||
.rounded_md()
|
||||
.refine_style(&self.style)
|
||||
.bg(color)
|
||||
.with_animation(
|
||||
"skeleton",
|
||||
Animation::new(Duration::from_secs(2))
|
||||
.repeat()
|
||||
@@ -56,7 +63,6 @@ impl RenderOnce for Skeleton {
|
||||
let v = 1.0 - delta * 0.5;
|
||||
this.opacity(v)
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::display::DisplayProfile;
|
||||
use common::display::ReadableProfile;
|
||||
use gpui::{
|
||||
AnyElement, AnyView, App, ElementId, HighlightStyle, InteractiveText, IntoElement,
|
||||
SharedString, StyledText, UnderlineStyle, Window,
|
||||
|
||||
Reference in New Issue
Block a user