Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fff3a44f62 | ||
|
|
9abcc25f32 | ||
|
|
fb3da096f8 | ||
|
|
1de3045505 | ||
|
|
9f369bf57f | ||
|
|
4164651342 | ||
|
|
c12856cda0 | ||
|
|
c67b223a53 | ||
|
|
9880a3ed3d | ||
|
|
d13ffd5a54 | ||
|
|
cc79f0ed1c | ||
|
|
5127eaadbb | ||
|
|
d38e70ecbf | ||
|
|
b142982ab1 | ||
|
|
2ea2519e8b | ||
|
|
2ea5feaf4b | ||
|
|
4ec7530b91 |
@@ -157,7 +157,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
name: Release ${{ steps.version.outputs.tag }}
|
||||
name: ${{ steps.version.outputs.tag }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
|
||||
Generated
+317
-245
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -4,7 +4,7 @@ members = ["crates/*"]
|
||||
default-members = ["crates/coop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.6"
|
||||
version = "0.2.9"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -58,3 +58,7 @@ opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[profile.profiling]
|
||||
inherits = "release"
|
||||
debug = true
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.5" d="M21.25 12V6.75a2 2 0 0 0-2-2H4.75a2 2 0 0 0-2 2V12m18.5 0H2.75m18.5 0v5.25a2 2 0 0 1-2 2H4.75a2 2 0 0 1-2-2V12"/>
|
||||
<path fill="currentColor" stroke="currentColor" stroke-width=".5" d="M6.5 14.875a.75.75 0 1 1 0 1.5.75.75 0 0 1 0-1.5Zm0-7.25a.75.75 0 1 1 0 1.5.75.75 0 0 1 0-1.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -1,5 +1,7 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use global::app_state;
|
||||
use global::constants::KEYRING_URL;
|
||||
use global::first_run;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Window};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
@@ -59,6 +61,7 @@ impl ClientKeys {
|
||||
return;
|
||||
}
|
||||
|
||||
let app_state = app_state();
|
||||
let read_client_keys = cx.read_credentials(KEYRING_URL);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
@@ -73,7 +76,7 @@ impl ClientKeys {
|
||||
this.set_keys(Some(keys), false, true, cx);
|
||||
})
|
||||
.ok();
|
||||
} else if *first_run() {
|
||||
} else if app_state.is_first_run.load(Ordering::Acquire) {
|
||||
// If this is the first run, generate new keys and use them for the client keys
|
||||
this.update(cx, |this, cx| {
|
||||
this.new_keys(cx);
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use anyhow::{anyhow, Error};
|
||||
use chrono::{Local, TimeZone};
|
||||
use global::constants::IMAGE_RESIZE_SERVICE;
|
||||
use gpui::{Image, ImageFormat};
|
||||
use gpui::{Image, ImageFormat, SharedString, SharedUri};
|
||||
use nostr_sdk::prelude::*;
|
||||
use qrcode::render::svg;
|
||||
use qrcode::QrCode;
|
||||
@@ -15,87 +15,92 @@ const HOURS_IN_DAY: i64 = 24;
|
||||
const DAYS_IN_MONTH: i64 = 30;
|
||||
const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png";
|
||||
|
||||
pub trait ReadableProfile {
|
||||
fn avatar_url(&self, proxy: bool) -> String;
|
||||
fn display_name(&self) -> String;
|
||||
pub trait RenderedProfile {
|
||||
fn avatar(&self, proxy: bool) -> SharedUri;
|
||||
fn display_name(&self) -> SharedString;
|
||||
}
|
||||
|
||||
impl ReadableProfile for Profile {
|
||||
fn avatar_url(&self, proxy: bool) -> String {
|
||||
impl RenderedProfile for Profile {
|
||||
fn avatar(&self, proxy: bool) -> SharedUri {
|
||||
self.metadata()
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| {
|
||||
if proxy {
|
||||
format!(
|
||||
let url = format!(
|
||||
"{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1"
|
||||
)
|
||||
);
|
||||
|
||||
SharedUri::from(url)
|
||||
} else {
|
||||
picture.into()
|
||||
SharedUri::from(picture)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||
.unwrap_or_else(|| SharedUri::from("brand/avatar.png"))
|
||||
}
|
||||
|
||||
fn display_name(&self) -> String {
|
||||
fn display_name(&self) -> SharedString {
|
||||
if let Some(display_name) = self.metadata().display_name.as_ref() {
|
||||
if !display_name.is_empty() {
|
||||
return display_name.into();
|
||||
return SharedString::from(display_name);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = self.metadata().name.as_ref() {
|
||||
if !name.is_empty() {
|
||||
return name.into();
|
||||
return SharedString::from(name);
|
||||
}
|
||||
}
|
||||
|
||||
shorten_pubkey(self.public_key(), 4)
|
||||
SharedString::from(shorten_pubkey(self.public_key(), 4))
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ReadableTimestamp {
|
||||
fn to_human_time(&self) -> String;
|
||||
fn to_ago(&self) -> String;
|
||||
pub trait RenderedTimestamp {
|
||||
fn to_human_time(&self) -> SharedString;
|
||||
fn to_ago(&self) -> SharedString;
|
||||
}
|
||||
|
||||
impl ReadableTimestamp for Timestamp {
|
||||
fn to_human_time(&self) -> String {
|
||||
impl RenderedTimestamp for Timestamp {
|
||||
fn to_human_time(&self) -> SharedString {
|
||||
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "9999".into(),
|
||||
_ => return SharedString::from("9999"),
|
||||
};
|
||||
|
||||
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")),
|
||||
date if date == now_date => SharedString::from(format!("Today at {time_format}")),
|
||||
date if date == yesterday_date => {
|
||||
SharedString::from(format!("Yesterday at {time_format}"))
|
||||
}
|
||||
_ => SharedString::from(format!("{}, {time_format}", input_time.format("%d/%m/%y"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_ago(&self) -> String {
|
||||
fn to_ago(&self) -> SharedString {
|
||||
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "1m".into(),
|
||||
_ => return SharedString::from("1m"),
|
||||
};
|
||||
|
||||
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(),
|
||||
d if d.num_seconds() < SECONDS_IN_MINUTE => SharedString::from(NOW),
|
||||
d if d.num_minutes() < MINUTES_IN_HOUR => {
|
||||
SharedString::from(format!("{}m", d.num_minutes()))
|
||||
}
|
||||
d if d.num_hours() < HOURS_IN_DAY => SharedString::from(format!("{}h", d.num_hours())),
|
||||
d if d.num_days() < DAYS_IN_MONTH => SharedString::from(format!("{}d", d.num_days())),
|
||||
_ => SharedString::from(input_time.format("%b %d").to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,3 +45,39 @@ impl EventUtils for Event {
|
||||
a == b
|
||||
}
|
||||
}
|
||||
|
||||
impl EventUtils for UnsignedEvent {
|
||||
fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<PublicKey> = vec![];
|
||||
|
||||
// Add all public keys from event
|
||||
pubkeys.push(self.pubkey);
|
||||
pubkeys.extend(self.tags.public_keys().collect::<Vec<_>>());
|
||||
|
||||
// Generate unique hash
|
||||
pubkeys
|
||||
.into_iter()
|
||||
.unique()
|
||||
.sorted()
|
||||
.collect::<Vec<_>>()
|
||||
.hash(&mut hasher);
|
||||
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn all_pubkeys(&self) -> Vec<PublicKey> {
|
||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
||||
public_keys.push(self.pubkey);
|
||||
|
||||
public_keys
|
||||
}
|
||||
|
||||
fn compare_pubkeys(&self, other: &[PublicKey]) -> bool {
|
||||
let pubkeys = self.all_pubkeys();
|
||||
let a: HashSet<_> = pubkeys.iter().collect();
|
||||
let b: HashSet<_> = other.iter().collect();
|
||||
|
||||
a == b
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
use nostr_connect::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoopAuthUrlHandler;
|
||||
|
||||
impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
|
||||
Box::pin(async move {
|
||||
log::info!("Received Auth URL: {auth_url}");
|
||||
webbrowser::open(auth_url.as_str())?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
pub mod debounced_delay;
|
||||
pub mod display;
|
||||
pub mod event;
|
||||
pub mod handle_auth;
|
||||
pub mod nip05;
|
||||
pub mod nip96;
|
||||
|
||||
@@ -14,7 +14,7 @@ product-name = "Coop"
|
||||
description = "Chat Freely, Stay Private on Nostr"
|
||||
identifier = "su.reya.coop"
|
||||
category = "SocialNetworking"
|
||||
version = "0.2.6"
|
||||
version = "0.2.9"
|
||||
out-dir = "../../dist"
|
||||
before-packaging-command = "cargo build --release"
|
||||
resources = ["Cargo.toml", "src"]
|
||||
@@ -62,5 +62,5 @@ oneshot.workspace = true
|
||||
flume.workspace = true
|
||||
webbrowser.workspace = true
|
||||
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
indexset = "0.12.3"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use gpui::{actions, App};
|
||||
use nostr_connect::prelude::*;
|
||||
|
||||
actions!(coop, [DarkMode, Settings, Logout, Quit]);
|
||||
actions!(coop, [ReloadMetadata, DarkMode, Settings, Logout, Quit]);
|
||||
actions!(sidebar, [Reload, RelayStatus]);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoopAuthUrlHandler;
|
||||
|
||||
impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
|
||||
Box::pin(async move {
|
||||
log::info!("Received Auth URL: {auth_url}");
|
||||
webbrowser::open(auth_url.as_str())?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_embedded_fonts(cx: &App) {
|
||||
let asset_source = cx.asset_source();
|
||||
let font_paths = asset_source.list("fonts").unwrap();
|
||||
|
||||
+255
-184
@@ -7,19 +7,18 @@ use std::time::Duration;
|
||||
use anyhow::{anyhow, Error};
|
||||
use auto_update::AutoUpdater;
|
||||
use client_keys::ClientKeys;
|
||||
use common::display::ReadableProfile;
|
||||
use common::display::RenderedProfile;
|
||||
use common::event::EventUtils;
|
||||
use flume::{Receiver, Sender};
|
||||
use global::constants::{
|
||||
ACCOUNT_IDENTIFIER, BOOTSTRAP_RELAYS, DEFAULT_SIDEBAR_WIDTH, METADATA_BATCH_LIMIT,
|
||||
METADATA_BATCH_TIMEOUT, SEARCH_RELAYS,
|
||||
};
|
||||
use global::{css, ingester, nostr_client, AuthRequest, Notice, Signal, UnwrappingStatus};
|
||||
use global::{app_state, nostr_client, AuthRequest, Notice, SignalKind, UnwrappingStatus};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, rems, App, AppContext, AsyncWindowContext, Axis, Context, Entity, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
|
||||
Subscription, Task, WeakEntity, Window,
|
||||
deferred, div, px, rems, App, AppContext, AsyncWindowContext, Axis, Context, Entity,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window,
|
||||
};
|
||||
use i18n::{shared_t, t};
|
||||
use itertools::Itertools;
|
||||
@@ -42,7 +41,7 @@ use ui::notification::Notification;
|
||||
use ui::popup_menu::PopupMenuExt;
|
||||
use ui::{h_flex, v_flex, ContextModal, Disableable, IconName, Root, Sizable, StyledExt};
|
||||
|
||||
use crate::actions::{DarkMode, Logout, Settings};
|
||||
use crate::actions::{DarkMode, Logout, ReloadMetadata, Settings};
|
||||
use crate::views::compose::compose_button;
|
||||
use crate::views::setup_relay::setup_nip17_relay;
|
||||
use crate::views::{
|
||||
@@ -64,18 +63,22 @@ pub fn new_account(window: &mut Window, cx: &mut App) {
|
||||
}
|
||||
|
||||
pub struct ChatSpace {
|
||||
// Workspace
|
||||
// App's Title Bar
|
||||
title_bar: Entity<TitleBar>,
|
||||
|
||||
// App's Dock Area
|
||||
dock: Entity<DockArea>,
|
||||
|
||||
// Temporarily store all authentication requests
|
||||
auth_requests: HashMap<AuthRequest, bool>,
|
||||
// All authentication requests
|
||||
auth_requests: Entity<HashMap<RelayUrl, AuthRequest>>,
|
||||
|
||||
// Local state to determine if the user has set up NIP-17 relays
|
||||
has_nip17_relays: bool,
|
||||
nip17_relays: bool,
|
||||
|
||||
// System
|
||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||
// All subscriptions for observing the app state
|
||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||
|
||||
// All long running tasks
|
||||
_tasks: SmallVec<[Task<()>; 5]>,
|
||||
}
|
||||
|
||||
@@ -87,11 +90,18 @@ impl ChatSpace {
|
||||
|
||||
let title_bar = cx.new(|_| TitleBar::new());
|
||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||
let auth_requests = cx.new(|_| HashMap::new());
|
||||
|
||||
let (pubkey_tx, pubkey_rx) = flume::bounded::<PublicKey>(1024);
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Automatically sync theme with system appearance
|
||||
window.observe_window_appearance(|window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the client keys and show an alert modal if they fail to initialize
|
||||
cx.observe_in(&client_keys, window, |this, keys, window, cx| {
|
||||
@@ -143,7 +153,7 @@ impl ChatSpace {
|
||||
.await
|
||||
.expect("Failed connect the bootstrap relays. Please restart the application.");
|
||||
|
||||
Self::process_nostr_events(&pubkey_tx)
|
||||
Self::process_nostr_events()
|
||||
.await
|
||||
.expect("Failed to handle nostr events. Please restart the application.");
|
||||
}),
|
||||
@@ -167,7 +177,7 @@ impl ChatSpace {
|
||||
tasks.push(
|
||||
// Listen all metadata requests then batch them into single subscription
|
||||
cx.background_spawn(async move {
|
||||
Self::process_batching_metadata(&pubkey_rx).await;
|
||||
Self::process_batching_metadata().await;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -181,8 +191,8 @@ impl ChatSpace {
|
||||
Self {
|
||||
dock,
|
||||
title_bar,
|
||||
auth_requests: HashMap::new(),
|
||||
has_nip17_relays: true,
|
||||
auth_requests,
|
||||
nip17_relays: true,
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
@@ -211,75 +221,83 @@ impl ChatSpace {
|
||||
|
||||
async fn observe_signer() {
|
||||
let client = nostr_client();
|
||||
let ingester = ingester();
|
||||
let app_state = app_state();
|
||||
let stream_timeout = Duration::from_secs(5);
|
||||
let loop_duration = Duration::from_secs(1);
|
||||
let mut is_sent_signal = false;
|
||||
let mut identity: Option<PublicKey> = None;
|
||||
|
||||
loop {
|
||||
if let Some(public_key) = identity {
|
||||
let nip65 = Filter::new().kind(Kind::RelayList).author(public_key);
|
||||
let Ok(signer) = client.signer().await else {
|
||||
smol::Timer::after(loop_duration).await;
|
||||
continue;
|
||||
};
|
||||
|
||||
if client.database().count(nip65).await.unwrap_or(0) > 0 {
|
||||
let dm_relays = Filter::new().kind(Kind::InboxRelays).author(public_key);
|
||||
let Ok(public_key) = signer.get_public_key().await else {
|
||||
smol::Timer::after(loop_duration).await;
|
||||
continue;
|
||||
};
|
||||
|
||||
match client.database().query(dm_relays).await {
|
||||
Ok(events) => {
|
||||
if let Some(event) = events.first_owned() {
|
||||
let relay_urls = nip17::extract_relay_list(&event).collect_vec();
|
||||
// Notify the app that the signer has been set.
|
||||
app_state
|
||||
.signal
|
||||
.send(SignalKind::SignerSet(public_key))
|
||||
.await;
|
||||
|
||||
if relay_urls.is_empty() {
|
||||
if !is_sent_signal {
|
||||
ingester.send(Signal::DmRelayNotFound).await;
|
||||
is_sent_signal = true;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else if !is_sent_signal {
|
||||
ingester.send(Signal::DmRelayNotFound).await;
|
||||
is_sent_signal = true;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Database query error: {e}");
|
||||
if !is_sent_signal {
|
||||
ingester.send(Signal::DmRelayNotFound).await;
|
||||
is_sent_signal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("Database error.");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Wait for signer set
|
||||
if let Ok(signer) = client.signer().await {
|
||||
if let Ok(public_key) = signer.get_public_key().await {
|
||||
identity = Some(public_key);
|
||||
// Subscribe to the NIP-65 relays for the public key.
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::RelayList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Notify the app that the signer has been set.
|
||||
ingester.send(Signal::SignerSet(public_key)).await;
|
||||
let mut nip65_found = false;
|
||||
|
||||
// Subscribe to the NIP-65 relays for the public key.
|
||||
if let Err(e) = Self::fetch_nip65_relays(public_key).await {
|
||||
log::error!("Failed to fetch NIP-65 relays: {e}");
|
||||
}
|
||||
match client
|
||||
.stream_events_from(BOOTSTRAP_RELAYS, filter, stream_timeout)
|
||||
.await
|
||||
{
|
||||
Ok(mut stream) => {
|
||||
if stream.next().await.is_some() {
|
||||
nip65_found = true;
|
||||
} else {
|
||||
// Timeout
|
||||
app_state.signal.send(SignalKind::RelaysNotFound).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error fetching NIP-65 Relay: {e:?}");
|
||||
app_state.signal.send(SignalKind::RelaysNotFound).await;
|
||||
}
|
||||
};
|
||||
|
||||
if nip65_found {
|
||||
// Subscribe to the NIP-17 relays for the public key.
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
match client.stream_events(filter, stream_timeout).await {
|
||||
Ok(mut stream) => {
|
||||
if stream.next().await.is_some() {
|
||||
break;
|
||||
} else {
|
||||
// Timeout
|
||||
app_state.signal.send(SignalKind::RelaysNotFound).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error fetching NIP-17 Relay: {e:?}");
|
||||
app_state.signal.send(SignalKind::RelaysNotFound).await;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
smol::Timer::after(loop_duration).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async fn observe_giftwrap() {
|
||||
let client = nostr_client();
|
||||
let css = css();
|
||||
let ingester = ingester();
|
||||
let app_state = app_state();
|
||||
let loop_duration = Duration::from_secs(20);
|
||||
let mut is_start_processing = false;
|
||||
let mut total_loops = 0;
|
||||
@@ -288,25 +306,25 @@ impl ChatSpace {
|
||||
if client.has_signer().await {
|
||||
total_loops += 1;
|
||||
|
||||
if css.gift_wrap_processing.load(Ordering::Acquire) {
|
||||
if app_state.gift_wrap_processing.load(Ordering::Acquire) {
|
||||
is_start_processing = true;
|
||||
|
||||
// Reset gift wrap processing flag
|
||||
let _ = css.gift_wrap_processing.compare_exchange(
|
||||
let _ = app_state.gift_wrap_processing.compare_exchange(
|
||||
true,
|
||||
false,
|
||||
Ordering::Release,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
|
||||
let signal = Signal::GiftWrapProcess(UnwrappingStatus::Processing);
|
||||
ingester.send(signal).await;
|
||||
let signal = SignalKind::GiftWrapStatus(UnwrappingStatus::Processing);
|
||||
app_state.signal.send(signal).await;
|
||||
} else {
|
||||
// Only run further if we are already processing
|
||||
// Wait until after 2 loops to prevent exiting early while events are still being processed
|
||||
if is_start_processing && total_loops >= 2 {
|
||||
let signal = Signal::GiftWrapProcess(UnwrappingStatus::Complete);
|
||||
ingester.send(signal).await;
|
||||
let signal = SignalKind::GiftWrapStatus(UnwrappingStatus::Complete);
|
||||
app_state.signal.send(signal).await;
|
||||
|
||||
// Reset the counter
|
||||
is_start_processing = false;
|
||||
@@ -319,7 +337,8 @@ impl ChatSpace {
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_batching_metadata(rx: &Receiver<PublicKey>) {
|
||||
async fn process_batching_metadata() {
|
||||
let app_state = app_state();
|
||||
let timeout = Duration::from_millis(METADATA_BATCH_TIMEOUT);
|
||||
let mut processed_pubkeys: HashSet<PublicKey> = HashSet::new();
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
@@ -334,7 +353,7 @@ impl ChatSpace {
|
||||
loop {
|
||||
let futs = smol::future::or(
|
||||
async move {
|
||||
if let Ok(public_key) = rx.recv_async().await {
|
||||
if let Ok(public_key) = app_state.ingester.receiver().recv_async().await {
|
||||
BatchEvent::PublicKey(public_key)
|
||||
} else {
|
||||
BatchEvent::Closed
|
||||
@@ -369,10 +388,9 @@ impl ChatSpace {
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_nostr_events(pubkey_tx: &Sender<PublicKey>) -> Result<(), Error> {
|
||||
async fn process_nostr_events() -> Result<(), Error> {
|
||||
let client = nostr_client();
|
||||
let ingester = ingester();
|
||||
let css = css();
|
||||
let app_state = app_state();
|
||||
|
||||
let mut processed_events: HashSet<EventId> = HashSet::new();
|
||||
let mut challenges: HashSet<Cow<'_, str>> = HashSet::new();
|
||||
@@ -385,6 +403,15 @@ impl ChatSpace {
|
||||
|
||||
match message {
|
||||
RelayMessage::Event { event, .. } => {
|
||||
// Keep track of which relays have seen this event
|
||||
app_state
|
||||
.seen_on_relays
|
||||
.write()
|
||||
.await
|
||||
.entry(event.id)
|
||||
.or_insert_with(HashSet::new)
|
||||
.insert(relay_url);
|
||||
|
||||
// Skip events that have already been processed
|
||||
if !processed_events.insert(event.id) {
|
||||
continue;
|
||||
@@ -398,9 +425,6 @@ impl ChatSpace {
|
||||
|
||||
// Fetch user's contact list event
|
||||
Self::fetch_single_event(Kind::ContactList, event.pubkey).await;
|
||||
|
||||
// Fetch user's inbox relays event
|
||||
Self::fetch_single_event(Kind::InboxRelays, event.pubkey).await;
|
||||
}
|
||||
}
|
||||
Kind::InboxRelays => {
|
||||
@@ -411,16 +435,18 @@ impl ChatSpace {
|
||||
for relay in relays.clone().into_iter() {
|
||||
if client.add_relay(relay).await.is_err() {
|
||||
let notice = Notice::RelayFailed(relay.clone());
|
||||
ingester.send(Signal::Notice(notice)).await;
|
||||
app_state.signal.send(SignalKind::Notice(notice)).await;
|
||||
}
|
||||
if client.connect_relay(relay).await.is_err() {
|
||||
let notice = Notice::RelayFailed(relay.clone());
|
||||
ingester.send(Signal::Notice(notice)).await;
|
||||
app_state.signal.send(SignalKind::Notice(notice)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to gift wrap events only in the current user's NIP-17 relays
|
||||
Self::fetch_gift_wrap(relays, event.pubkey).await;
|
||||
} else {
|
||||
app_state.signal.send(SignalKind::RelaysNotFound).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -433,43 +459,54 @@ impl ChatSpace {
|
||||
Filter::new().limit(limit).authors(public_keys).kinds(kinds);
|
||||
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, css.auto_close_opts)
|
||||
.subscribe_to(
|
||||
BOOTSTRAP_RELAYS,
|
||||
filter,
|
||||
app_state.auto_close_opts,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
Kind::Metadata => {
|
||||
ingester.send(Signal::Metadata(event.into_owned())).await;
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
let profile = Profile::new(event.pubkey, metadata);
|
||||
|
||||
app_state.signal.send(SignalKind::NewProfile(profile)).await;
|
||||
}
|
||||
Kind::GiftWrap => {
|
||||
Self::unwrap_gift_wrap(&event, pubkey_tx).await;
|
||||
Self::unwrap_gift_wrap(&event).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(subscription_id) => {
|
||||
if *subscription_id == css.gift_wrap_sub_id {
|
||||
let signal = Signal::GiftWrapProcess(UnwrappingStatus::Processing);
|
||||
ingester.send(signal).await;
|
||||
if *subscription_id == app_state.gift_wrap_sub_id {
|
||||
let signal = SignalKind::GiftWrapStatus(UnwrappingStatus::Processing);
|
||||
app_state.signal.send(signal).await;
|
||||
}
|
||||
}
|
||||
RelayMessage::Auth { challenge } => {
|
||||
if challenges.insert(challenge.clone()) {
|
||||
let req = AuthRequest::new(challenge, relay_url);
|
||||
// Send a signal to the ingester to handle the auth request
|
||||
ingester.send(Signal::Auth(req)).await;
|
||||
app_state.signal.send(SignalKind::Auth(req)).await;
|
||||
}
|
||||
}
|
||||
RelayMessage::Ok {
|
||||
event_id, message, ..
|
||||
} => {
|
||||
// Keep track of events sent by Coop
|
||||
css.sent_ids.write().await.insert(event_id);
|
||||
app_state.sent_ids.write().await.insert(event_id);
|
||||
|
||||
// Keep track of events that need to be resent
|
||||
match MachineReadablePrefix::parse(&message) {
|
||||
Some(MachineReadablePrefix::AuthRequired) => {
|
||||
css.resend_queue.write().await.insert(event_id, relay_url);
|
||||
app_state
|
||||
.resend_queue
|
||||
.write()
|
||||
.await
|
||||
.insert(event_id, relay_url);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {}
|
||||
@@ -483,17 +520,16 @@ impl ChatSpace {
|
||||
}
|
||||
|
||||
async fn process_nostr_signals(view: WeakEntity<ChatSpace>, cx: &mut AsyncWindowContext) {
|
||||
let ingester = ingester();
|
||||
let signals = ingester.signals();
|
||||
let app_state = app_state();
|
||||
let mut is_open_proxy_modal = false;
|
||||
|
||||
while let Ok(signal) = signals.recv_async().await {
|
||||
while let Ok(signal) = app_state.signal.receiver().recv_async().await {
|
||||
cx.update(|window, cx| {
|
||||
let registry = Registry::global(cx);
|
||||
let settings = AppSettings::global(cx);
|
||||
|
||||
match signal {
|
||||
Signal::SignerSet(public_key) => {
|
||||
SignalKind::SignerSet(public_key) => {
|
||||
window.close_modal(cx);
|
||||
|
||||
// Setup the default layout for current workspace
|
||||
@@ -513,7 +549,7 @@ impl ChatSpace {
|
||||
this.load_rooms(window, cx);
|
||||
});
|
||||
}
|
||||
Signal::SignerUnset => {
|
||||
SignalKind::SignerUnset => {
|
||||
// Setup the onboarding layout for current workspace
|
||||
view.update(cx, |this, cx| {
|
||||
this.set_onboarding_layout(window, cx);
|
||||
@@ -525,7 +561,7 @@ impl ChatSpace {
|
||||
this.reset(cx);
|
||||
});
|
||||
}
|
||||
Signal::Auth(req) => {
|
||||
SignalKind::Auth(req) => {
|
||||
let url = &req.url;
|
||||
let auto_auth = AppSettings::get_auto_auth(cx);
|
||||
let is_authenticated = AppSettings::read_global(cx).is_authenticated(url);
|
||||
@@ -543,7 +579,7 @@ impl ChatSpace {
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Signal::ProxyDown => {
|
||||
SignalKind::ProxyDown => {
|
||||
if !is_open_proxy_modal {
|
||||
is_open_proxy_modal = true;
|
||||
|
||||
@@ -553,28 +589,28 @@ impl ChatSpace {
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
Signal::GiftWrapProcess(status) => {
|
||||
SignalKind::GiftWrapStatus(status) => {
|
||||
registry.update(cx, |this, cx| {
|
||||
this.set_unwrapping_status(status, cx);
|
||||
});
|
||||
}
|
||||
Signal::Metadata(event) => {
|
||||
SignalKind::NewProfile(profile) => {
|
||||
registry.update(cx, |this, cx| {
|
||||
this.insert_or_update_person(event, cx);
|
||||
this.insert_or_update_person(profile, cx);
|
||||
});
|
||||
}
|
||||
Signal::Message((gift_wrap_id, event)) => {
|
||||
SignalKind::NewMessage((gift_wrap_id, event)) => {
|
||||
registry.update(cx, |this, cx| {
|
||||
this.event_to_message(gift_wrap_id, event, window, cx);
|
||||
});
|
||||
}
|
||||
Signal::DmRelayNotFound => {
|
||||
SignalKind::RelaysNotFound => {
|
||||
view.update(cx, |this, cx| {
|
||||
this.set_no_nip17_relays(cx);
|
||||
this.set_required_relays(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Signal::Notice(msg) => {
|
||||
SignalKind::Notice(msg) => {
|
||||
window.push_notification(msg.as_str(), cx);
|
||||
}
|
||||
};
|
||||
@@ -595,21 +631,22 @@ impl ChatSpace {
|
||||
/// Fetches a single event by kind and public key
|
||||
pub async fn fetch_single_event(kind: Kind, public_key: PublicKey) {
|
||||
let client = nostr_client();
|
||||
let css = css();
|
||||
let app_state = app_state();
|
||||
let filter = Filter::new().kind(kind).author(public_key).limit(1);
|
||||
|
||||
if let Err(e) = client.subscribe(filter, css.auto_close_opts).await {
|
||||
if let Err(e) = client.subscribe(filter, app_state.auto_close_opts).await {
|
||||
log::info!("Failed to subscribe: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches gift wrap events for a given public key and relays
|
||||
pub async fn fetch_gift_wrap(relays: Vec<&RelayUrl>, public_key: PublicKey) {
|
||||
let client = nostr_client();
|
||||
let sub_id = css().gift_wrap_sub_id.clone();
|
||||
let id = app_state().gift_wrap_sub_id.clone();
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
|
||||
if client
|
||||
.subscribe_with_id_to(relays.clone(), sub_id, filter, None)
|
||||
.subscribe_with_id_to(relays.clone(), id, filter, None)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
@@ -617,23 +654,6 @@ impl ChatSpace {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches NIP-65 relay list for a given public key
|
||||
pub async fn fetch_nip65_relays(public_key: PublicKey) -> Result<(), Error> {
|
||||
let client = nostr_client();
|
||||
let css = css();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::RelayList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, css.auto_close_opts)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetches metadata for a list of public keys
|
||||
async fn fetch_metadata_for_pubkeys(public_keys: HashSet<PublicKey>) {
|
||||
if public_keys.is_empty() {
|
||||
@@ -641,7 +661,7 @@ impl ChatSpace {
|
||||
}
|
||||
|
||||
let client = nostr_client();
|
||||
let css = css();
|
||||
let app_state = app_state();
|
||||
|
||||
let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList];
|
||||
let limit = public_keys.len() * kinds.len() + 20;
|
||||
@@ -650,7 +670,7 @@ impl ChatSpace {
|
||||
let filter = Filter::new().authors(public_keys).kinds(kinds).limit(limit);
|
||||
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, css.auto_close_opts)
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, app_state.auto_close_opts)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
@@ -696,10 +716,9 @@ impl ChatSpace {
|
||||
}
|
||||
|
||||
/// Unwraps a gift-wrapped event and processes its contents.
|
||||
async fn unwrap_gift_wrap(target: &Event, pubkey_tx: &Sender<PublicKey>) {
|
||||
async fn unwrap_gift_wrap(target: &Event) {
|
||||
let client = nostr_client();
|
||||
let ingester = ingester();
|
||||
let css = css();
|
||||
let app_state = app_state();
|
||||
let mut message: Option<Event> = None;
|
||||
|
||||
if let Ok(event) = Self::get_unwrapped_event(target.id).await {
|
||||
@@ -719,20 +738,24 @@ impl ChatSpace {
|
||||
if let Some(event) = message {
|
||||
// Send all pubkeys to the metadata batch to sync data
|
||||
for public_key in event.all_pubkeys() {
|
||||
pubkey_tx.send_async(public_key).await.ok();
|
||||
app_state.ingester.send(public_key).await;
|
||||
}
|
||||
|
||||
match event.created_at >= css.init_at {
|
||||
match event.created_at >= app_state.init_at {
|
||||
// New message: send a signal to notify the UI
|
||||
true => {
|
||||
// Prevent notification if the event was sent by Coop
|
||||
if !css.sent_ids.read().await.contains(&target.id) {
|
||||
ingester.send(Signal::Message((target.id, event))).await;
|
||||
}
|
||||
// A small delay to prevent UI flickering
|
||||
smol::Timer::after(Duration::from_millis(200)).await;
|
||||
app_state
|
||||
.signal
|
||||
.send(SignalKind::NewMessage((target.id, event)))
|
||||
.await;
|
||||
}
|
||||
// Old message: Coop is probably processing the user's messages during initial load
|
||||
false => {
|
||||
css.gift_wrap_processing.store(true, Ordering::Release);
|
||||
app_state
|
||||
.gift_wrap_processing
|
||||
.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -783,7 +806,7 @@ impl ChatSpace {
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let css = css();
|
||||
let app_state = app_state();
|
||||
let signer = client.signer().await?;
|
||||
|
||||
// Construct event
|
||||
@@ -814,7 +837,7 @@ impl ChatSpace {
|
||||
relay.resubscribe().await?;
|
||||
|
||||
// Get all failed events that need to be resent
|
||||
let mut queue = css.resend_queue.write().await;
|
||||
let mut queue = app_state.resend_queue.write().await;
|
||||
|
||||
let ids: Vec<EventId> = queue
|
||||
.iter()
|
||||
@@ -833,8 +856,8 @@ impl ChatSpace {
|
||||
success: HashSet::from([relay_url]),
|
||||
};
|
||||
|
||||
css.sent_ids.write().await.insert(event_id);
|
||||
css.resent_ids.write().await.push(output);
|
||||
app_state.sent_ids.write().await.insert(event_id);
|
||||
app_state.resent_ids.write().await.push(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -939,40 +962,47 @@ impl ChatSpace {
|
||||
}
|
||||
|
||||
fn reopen_auth_request(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
for req in self.auth_requests.clone().into_iter() {
|
||||
self.open_auth_request(req.0, window, cx);
|
||||
for (_, request) in self.auth_requests.read(cx).clone() {
|
||||
self.open_auth_request(request, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_auth_request(&mut self, req: &AuthRequest, cx: &mut Context<Self>) {
|
||||
self.auth_requests.insert(req.to_owned(), false);
|
||||
cx.notify();
|
||||
self.auth_requests.update(cx, |this, cx| {
|
||||
this.insert(req.url.clone(), req.to_owned());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn sending_auth_request(&mut self, challenge: &str, cx: &mut Context<Self>) {
|
||||
for (req, status) in self.auth_requests.iter_mut() {
|
||||
if req.challenge == challenge {
|
||||
*status = true;
|
||||
cx.notify();
|
||||
self.auth_requests.update(cx, |this, cx| {
|
||||
for (_, req) in this.iter_mut() {
|
||||
if req.challenge == challenge {
|
||||
req.sending = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn is_sending_auth_request(&self, challenge: &str, _cx: &App) -> bool {
|
||||
fn is_sending_auth_request(&self, challenge: &str, cx: &App) -> bool {
|
||||
if let Some(req) = self
|
||||
.auth_requests
|
||||
.read(cx)
|
||||
.iter()
|
||||
.find(|(req, _)| req.challenge == challenge)
|
||||
.find(|(_, req)| req.challenge == challenge)
|
||||
{
|
||||
req.1.to_owned()
|
||||
req.1.sending
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_auth_request(&mut self, challenge: &str, cx: &mut Context<Self>) {
|
||||
self.auth_requests.retain(|r, _| r.challenge != challenge);
|
||||
cx.notify();
|
||||
self.auth_requests.update(cx, |this, cx| {
|
||||
this.retain(|_, r| r.challenge != challenge);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn set_onboarding_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -992,7 +1022,7 @@ impl ChatSpace {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let panel = Arc::new(account::init(secret, profile, window, cx));
|
||||
let panel = Arc::new(account::init(profile, secret, window, cx));
|
||||
let center = DockItem::panel(panel);
|
||||
|
||||
self.dock.update(cx, |this, cx| {
|
||||
@@ -1023,8 +1053,8 @@ impl ChatSpace {
|
||||
});
|
||||
}
|
||||
|
||||
fn set_no_nip17_relays(&mut self, cx: &mut Context<Self>) {
|
||||
self.has_nip17_relays = false;
|
||||
fn set_required_relays(&mut self, cx: &mut Context<Self>) {
|
||||
self.nip17_relays = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -1051,19 +1081,13 @@ impl ChatSpace {
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok((secret, profile)) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_account_layout(secret, profile, window, cx);
|
||||
})
|
||||
.ok();
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_account_layout(secret, profile, window, cx);
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_onboarding_layout(window, cx);
|
||||
})
|
||||
.ok();
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_onboarding_layout(window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -1090,10 +1114,54 @@ impl ChatSpace {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_reload_metadata(
|
||||
&mut self,
|
||||
_ev: &ReloadMetadata,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let app_state = app_state();
|
||||
|
||||
let filter = Filter::new().kind(Kind::PrivateDirectMessage);
|
||||
|
||||
let pubkeys: Vec<PublicKey> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.flat_map(|event| event.all_pubkeys())
|
||||
.unique()
|
||||
.collect();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.limit(pubkeys.len())
|
||||
.authors(pubkeys);
|
||||
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, app_state.auto_close_opts)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(t!("common.refreshed"), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn on_sign_out(&mut self, _e: &Logout, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let ingester = ingester();
|
||||
let app_state = app_state();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
@@ -1106,7 +1174,7 @@ impl ChatSpace {
|
||||
client.reset().await;
|
||||
|
||||
// Notify the channel about the signer being unset
|
||||
ingester.send(Signal::SignerUnset).await;
|
||||
app_state.signal.send(SignalKind::SignerUnset).await;
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -1216,7 +1284,7 @@ impl ChatSpace {
|
||||
.w_full()
|
||||
.child(compose_button())
|
||||
.when(status != &UnwrappingStatus::Complete, |this| {
|
||||
this.child(
|
||||
this.child(deferred(
|
||||
h_flex()
|
||||
.px_2()
|
||||
.h_6()
|
||||
@@ -1225,7 +1293,7 @@ impl ChatSpace {
|
||||
.rounded_full()
|
||||
.bg(cx.theme().surface_background)
|
||||
.child(shared_t!("loading.label")),
|
||||
)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1236,10 +1304,9 @@ impl ChatSpace {
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||
let is_auto_auth = AppSettings::read_global(cx).is_auto_auth();
|
||||
let updating = AutoUpdater::read_global(cx).status.is_updating();
|
||||
let updated = AutoUpdater::read_global(cx).status.is_updated();
|
||||
let auth_requests = self.auth_requests.len();
|
||||
let auth_requests = self.auth_requests.read(cx).len();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
@@ -1275,7 +1342,7 @@ impl ChatSpace {
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(auth_requests > 0 && !is_auto_auth, |this| {
|
||||
.when(auth_requests > 0, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.id("requests")
|
||||
@@ -1295,7 +1362,7 @@ impl ChatSpace {
|
||||
})),
|
||||
)
|
||||
})
|
||||
.when(!self.has_nip17_relays, |this| {
|
||||
.when(!self.nip17_relays, |this| {
|
||||
this.child(setup_nip17_relay(t!("relays.button")))
|
||||
})
|
||||
.child(
|
||||
@@ -1304,11 +1371,13 @@ impl ChatSpace {
|
||||
.reverse()
|
||||
.transparent()
|
||||
.icon(IconName::CaretDown)
|
||||
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(1.49)))
|
||||
.child(Avatar::new(profile.avatar(proxy)).size(rems(1.49)))
|
||||
.popup_menu(|this, _window, _cx| {
|
||||
this.menu(t!("user.dark_mode"), Box::new(DarkMode))
|
||||
.menu(t!("user.settings"), Box::new(Settings))
|
||||
.separator()
|
||||
.menu(t!("user.reload_metadata"), Box::new(ReloadMetadata))
|
||||
.separator()
|
||||
.menu(t!("user.sign_out"), Box::new(Logout))
|
||||
}),
|
||||
)
|
||||
@@ -1329,7 +1398,7 @@ impl ChatSpace {
|
||||
|
||||
this._tasks.push(cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let ingester = ingester();
|
||||
let app_state = app_state();
|
||||
|
||||
if proxy.start().await.is_ok() {
|
||||
webbrowser::open(&url).ok();
|
||||
@@ -1360,7 +1429,7 @@ impl ChatSpace {
|
||||
|
||||
break;
|
||||
} else {
|
||||
ingester.send(Signal::ProxyDown).await;
|
||||
app_state.signal.send(SignalKind::ProxyDown).await;
|
||||
}
|
||||
smol::Timer::after(Duration::from_secs(1)).await;
|
||||
}
|
||||
@@ -1425,10 +1494,12 @@ impl Render for ChatSpace {
|
||||
}
|
||||
|
||||
div()
|
||||
.id(SharedString::from("chatspace"))
|
||||
.on_action(cx.listener(Self::on_settings))
|
||||
.on_action(cx.listener(Self::on_dark_mode))
|
||||
.on_action(cx.listener(Self::on_sign_out))
|
||||
.on_action(cx.listener(Self::on_open_profile))
|
||||
.on_action(cx.listener(Self::on_reload_metadata))
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
|
||||
+2
-13
@@ -2,13 +2,12 @@ use std::sync::Arc;
|
||||
|
||||
use assets::Assets;
|
||||
use global::constants::{APP_ID, APP_NAME};
|
||||
use global::{css, ingester, nostr_client};
|
||||
use global::{app_state, nostr_client};
|
||||
use gpui::{
|
||||
point, px, size, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SharedString,
|
||||
TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind,
|
||||
WindowOptions,
|
||||
};
|
||||
use theme::Theme;
|
||||
use ui::Root;
|
||||
|
||||
use crate::actions::{load_embedded_fonts, quit, Quit};
|
||||
@@ -26,11 +25,8 @@ fn main() {
|
||||
// Initialize the Nostr client
|
||||
let _client = nostr_client();
|
||||
|
||||
// Initialize the ingester
|
||||
let _ingester = ingester();
|
||||
|
||||
// Initialize the coop simple storage
|
||||
let _css = css();
|
||||
let _app_state = app_state();
|
||||
|
||||
// Initialize the Application
|
||||
let app = Application::new()
|
||||
@@ -82,13 +78,6 @@ fn main() {
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
|
||||
// Automatically sync theme with system appearance
|
||||
window
|
||||
.observe_window_appearance(|window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Root Entity
|
||||
cx.new(|cx| {
|
||||
// Initialize the tokio runtime
|
||||
|
||||
@@ -2,15 +2,15 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use client_keys::ClientKeys;
|
||||
use common::display::ReadableProfile;
|
||||
use common::handle_auth::CoopAuthUrlHandler;
|
||||
use common::display::RenderedProfile;
|
||||
use global::constants::{ACCOUNT_IDENTIFIER, BUNKER_TIMEOUT};
|
||||
use global::{ingester, nostr_client, Signal};
|
||||
use global::{app_state, nostr_client, SignalKind};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Task, WeakEntity, Window,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
|
||||
RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
|
||||
WeakEntity, Window,
|
||||
};
|
||||
use i18n::{shared_t, t};
|
||||
use nostr_connect::prelude::*;
|
||||
@@ -22,18 +22,20 @@ use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::notification::Notification;
|
||||
use ui::popup_menu::PopupMenu;
|
||||
use ui::{h_flex, v_flex, ContextModal, Disableable, Sizable, StyledExt};
|
||||
use ui::{h_flex, v_flex, ContextModal, Sizable, StyledExt};
|
||||
|
||||
use crate::actions::CoopAuthUrlHandler;
|
||||
use crate::chatspace::ChatSpace;
|
||||
|
||||
pub fn init(
|
||||
secret: String,
|
||||
profile: Profile,
|
||||
secret: String,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Account> {
|
||||
Account::new(secret, profile, window, cx)
|
||||
cx.new(|cx| Account::new(secret, profile, window, cx))
|
||||
}
|
||||
|
||||
pub struct Account {
|
||||
@@ -42,18 +44,33 @@ pub struct Account {
|
||||
is_bunker: bool,
|
||||
is_extension: bool,
|
||||
loading: bool,
|
||||
// Panel
|
||||
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
image_cache: Entity<RetainAllImageCache>,
|
||||
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
fn new(secret: String, profile: Profile, _window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
fn new(secret: String, profile: Profile, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let is_bunker = secret.starts_with("bunker://");
|
||||
let is_extension = secret.starts_with("extension");
|
||||
|
||||
cx.new(|cx| Self {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Clear the local state when user closes the account panel
|
||||
cx.on_release_in(window, move |this, window, cx| {
|
||||
this.stored_secret.clear();
|
||||
this.image_cache.update(cx, |this, cx| {
|
||||
this.clear(window, cx);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
profile,
|
||||
is_bunker,
|
||||
is_extension,
|
||||
@@ -61,8 +78,10 @@ impl Account {
|
||||
loading: false,
|
||||
name: "Account".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
image_cache: RetainAllImageCache::new(cx),
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: smallvec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -93,8 +112,8 @@ impl Account {
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
self._tasks.push(
|
||||
// Handle connection
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
// Handle connection in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let client = nostr_client();
|
||||
|
||||
match signer.bunker_uri().await {
|
||||
@@ -103,8 +122,9 @@ impl Account {
|
||||
client.set_signer(signer).await;
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_loading(false, cx);
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -248,7 +268,7 @@ impl Account {
|
||||
// Reset the nostr client in the background
|
||||
cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let ingester = ingester();
|
||||
let app_state = app_state();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
@@ -261,7 +281,7 @@ impl Account {
|
||||
client.unset_signer().await;
|
||||
|
||||
// Notify the channel about the signer being unset
|
||||
ingester.send(Signal::SignerUnset).await;
|
||||
app_state.signal.send(SignalKind::SignerUnset).await;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -301,6 +321,7 @@ impl Focusable for Account {
|
||||
impl Render for Account {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.image_cache(self.image_cache.clone())
|
||||
.relative()
|
||||
.size_full()
|
||||
.gap_10()
|
||||
@@ -342,46 +363,72 @@ impl Render for Account {
|
||||
.id("account")
|
||||
.h_10()
|
||||
.w_72()
|
||||
.bg(cx.theme().element_background)
|
||||
.text_color(cx.theme().element_foreground)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded_lg()
|
||||
.text_sm()
|
||||
.map(|this| {
|
||||
if self.loading {
|
||||
this.child(
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Indicator::new().small()),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.h_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(shared_t!("onboarding.choose_account"))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(self.profile.avatar_url(true))
|
||||
.size(rems(1.5)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.pb_px()
|
||||
.font_semibold()
|
||||
.child(self.profile.display_name()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
.when(self.loading, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Indicator::new().small()),
|
||||
)
|
||||
})
|
||||
.when(!self.loading, |this| {
|
||||
let avatar = self.profile.avatar(true);
|
||||
let name = self.profile.display_name();
|
||||
|
||||
this.child(
|
||||
h_flex()
|
||||
.h_full()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Avatar::new(avatar).size(rems(1.5)))
|
||||
.child(div().pb_px().font_semibold().child(name)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.when(self.is_bunker, |this| {
|
||||
let label = SharedString::from("Nostr Connect");
|
||||
|
||||
this.child(
|
||||
div()
|
||||
.py_0p5()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.bg(cx.theme().secondary_active)
|
||||
.text_color(
|
||||
cx.theme().secondary_foreground,
|
||||
)
|
||||
.rounded_full()
|
||||
.child(label),
|
||||
)
|
||||
})
|
||||
.when(self.is_extension, |this| {
|
||||
let label = SharedString::from("Extension");
|
||||
|
||||
this.child(
|
||||
div()
|
||||
.py_0p5()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.bg(cx.theme().secondary_active)
|
||||
.text_color(
|
||||
cx.theme().secondary_foreground,
|
||||
)
|
||||
.rounded_full()
|
||||
.child(label),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
.active(|this| this.bg(cx.theme().element_active))
|
||||
.hover(|this| this.bg(cx.theme().element_hover))
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.login(window, cx);
|
||||
@@ -391,7 +438,6 @@ impl Render for Account {
|
||||
Button::new("logout")
|
||||
.label(t!("user.sign_out"))
|
||||
.ghost()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.logout(window, cx);
|
||||
})),
|
||||
|
||||
+434
-201
@@ -1,13 +1,14 @@
|
||||
use anyhow::anyhow;
|
||||
use common::display::{ReadableProfile, ReadableTimestamp};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use common::display::{RenderedProfile, RenderedTimestamp};
|
||||
use common::nip96::nip96_upload;
|
||||
use global::{css, nostr_client};
|
||||
use global::{app_state, nostr_client};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, list, px, red, relative, rems, svg, white, Action, AnyElement, App, AppContext,
|
||||
ClipboardItem, Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit,
|
||||
ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString,
|
||||
ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString, SharedUri,
|
||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
@@ -29,7 +30,8 @@ use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::emoji_picker::EmojiPicker;
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::popup_menu::PopupMenu;
|
||||
use ui::notification::Notification;
|
||||
use ui::popup_menu::{PopupMenu, PopupMenuExt};
|
||||
use ui::text::RenderedText;
|
||||
use ui::{
|
||||
h_flex, v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable,
|
||||
@@ -40,7 +42,7 @@ mod subject;
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = chat, no_json)]
|
||||
pub struct ChangeSubject(pub String);
|
||||
pub struct SeenOn(pub EventId);
|
||||
|
||||
pub fn init(room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Chat> {
|
||||
cx.new(|cx| Chat::new(room, window, cx))
|
||||
@@ -49,6 +51,9 @@ pub fn init(room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Cha
|
||||
pub struct Chat {
|
||||
// Chat Room
|
||||
room: Entity<Room>,
|
||||
relays: Entity<HashMap<PublicKey, Vec<RelayUrl>>>,
|
||||
|
||||
// Messages
|
||||
list_state: ListState,
|
||||
messages: BTreeSet<Message>,
|
||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||
@@ -67,14 +72,20 @@ pub struct Chat {
|
||||
focus_handle: FocusHandle,
|
||||
image_cache: Entity<RetainAllImageCache>,
|
||||
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||
_tasks: SmallVec<[Task<()>; 2]>,
|
||||
}
|
||||
|
||||
impl Chat {
|
||||
pub fn new(room: Entity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let attachments = cx.new(|_| vec![]);
|
||||
let replies_to = cx.new(|_| vec![]);
|
||||
|
||||
let relays = cx.new(|_| {
|
||||
let this: HashMap<PublicKey, Vec<RelayUrl>> = HashMap::new();
|
||||
this
|
||||
});
|
||||
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("chat.placeholder"))
|
||||
@@ -89,11 +100,35 @@ impl Chat {
|
||||
let messages = BTreeSet::from([Message::system()]);
|
||||
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
|
||||
|
||||
let connect_relays = room.read(cx).connect_relays(cx);
|
||||
let load_messages = room.read(cx).load_messages(cx);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Load all messages belonging to this room
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match connect_relays.await {
|
||||
Ok(relays) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.relays.update(cx, |this, cx| {
|
||||
*this = relays;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Load all messages belonging to this room
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
@@ -139,7 +174,7 @@ impl Chat {
|
||||
match signal {
|
||||
RoomSignal::NewMessage((gift_wrap_id, event)) => {
|
||||
if !this.is_sent_by_coop(gift_wrap_id) {
|
||||
this.insert_message(event, false, cx);
|
||||
this.insert_message(Message::user(event), false, cx);
|
||||
}
|
||||
}
|
||||
RoomSignal::Refresh => {
|
||||
@@ -149,24 +184,66 @@ impl Chat {
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the messaging relays of the room's members
|
||||
cx.observe_in(&relays, window, |this, entity, _window, cx| {
|
||||
for (public_key, urls) in entity.read(cx).clone().into_iter() {
|
||||
if urls.is_empty() {
|
||||
let profile = Registry::read_global(cx).get_person(&public_key, cx);
|
||||
let content = t!("chat.nip17_not_found", u = profile.name());
|
||||
|
||||
this.insert_warning(content, cx);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe when user close chat panel
|
||||
cx.on_release_in(window, move |this, window, cx| {
|
||||
this.disconnect_relays(cx);
|
||||
this.messages.clear();
|
||||
this.rendered_texts_by_id.clear();
|
||||
this.reports_by_id.clear();
|
||||
this.image_cache.update(cx, |this, cx| {
|
||||
this.clear(window, cx);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
id: room.read(cx).id.to_string().into(),
|
||||
image_cache: RetainAllImageCache::new(cx),
|
||||
focus_handle: cx.focus_handle(),
|
||||
uploading: false,
|
||||
rendered_texts_by_id: BTreeMap::new(),
|
||||
reports_by_id: BTreeMap::new(),
|
||||
relays,
|
||||
messages,
|
||||
room,
|
||||
list_state,
|
||||
input,
|
||||
replies_to,
|
||||
attachments,
|
||||
uploading: false,
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect all relays when the user closes the chat panel
|
||||
fn disconnect_relays(&mut self, cx: &mut App) {
|
||||
let relays = self.relays.read(cx).clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
|
||||
for relay in relays.values().flatten() {
|
||||
client.disconnect_relay(relay).await.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Load all messages belonging to this room
|
||||
fn load_messages(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let load_messages = self.room.read(cx).load_messages(cx);
|
||||
@@ -205,15 +282,17 @@ impl Chat {
|
||||
let attachments = self.attachments.read(cx);
|
||||
|
||||
if !attachments.is_empty() {
|
||||
content = format!(
|
||||
"{}\n{}",
|
||||
content,
|
||||
attachments
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect_vec()
|
||||
.join("\n")
|
||||
)
|
||||
let urls = attachments
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect_vec()
|
||||
.join("\n");
|
||||
|
||||
if content.is_empty() {
|
||||
content = urls;
|
||||
} else {
|
||||
content = format!("{content}\n{urls}");
|
||||
}
|
||||
}
|
||||
|
||||
content
|
||||
@@ -221,7 +300,7 @@ impl Chat {
|
||||
|
||||
/// Check if the event is sent by Coop
|
||||
fn is_sent_by_coop(&self, gift_wrap_id: &EventId) -> bool {
|
||||
css().sent_ids.read_blocking().contains(gift_wrap_id)
|
||||
app_state().sent_ids.read_blocking().contains(gift_wrap_id)
|
||||
}
|
||||
|
||||
/// Send a message to all members of the chat
|
||||
@@ -260,11 +339,14 @@ impl Chat {
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
// Optimistically update message list
|
||||
this.insert_message(temp_message, true, cx);
|
||||
this.insert_message(Message::user(temp_message), true, cx);
|
||||
|
||||
// Remove all replies
|
||||
this.remove_all_replies(cx);
|
||||
|
||||
// remove all attachments
|
||||
this.remove_all_attachments(cx);
|
||||
|
||||
// Reset the input state
|
||||
this.input.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
@@ -307,6 +389,75 @@ impl Chat {
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn resend_message(&mut self, id: &EventId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(reports) = self.reports_by_id.get(id).cloned() {
|
||||
if let Some(message) = self.message(id) {
|
||||
let backup = AppSettings::get_backup_messages(cx);
|
||||
let id_clone = id.to_owned();
|
||||
let message = message.content.to_owned();
|
||||
let task = self.room.read(cx).resend(reports, message, backup, cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(reports) => {
|
||||
if !reports.is_empty() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.reports_by_id.entry(id_clone).and_modify(|this| {
|
||||
*this = reports;
|
||||
});
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a message into the chat panel
|
||||
fn insert_message<E>(&mut self, m: E, scroll: bool, cx: &mut Context<Self>)
|
||||
where
|
||||
E: Into<Message>,
|
||||
{
|
||||
let old_len = self.messages.len();
|
||||
|
||||
// Extend the messages list with the new events
|
||||
if self.messages.insert(m.into()) {
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
|
||||
if scroll {
|
||||
self.list_state.scroll_to(ListOffset {
|
||||
item_ix: self.list_state.item_count(),
|
||||
offset_in_item: px(0.0),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert and insert a vector of nostr events into the chat panel
|
||||
fn insert_messages(&mut self, events: Vec<Event>, cx: &mut Context<Self>) {
|
||||
for event in events.into_iter() {
|
||||
let m = Message::user(event);
|
||||
self.insert_message(m, false, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn insert_warning(&mut self, content: impl Into<String>, cx: &mut Context<Self>) {
|
||||
let m = Message::warning(content.into());
|
||||
self.insert_message(m, true, cx);
|
||||
}
|
||||
|
||||
/// Check if a message failed to send by its ID
|
||||
fn is_sent_failed(&self, id: &EventId) -> bool {
|
||||
self.reports_by_id
|
||||
@@ -338,35 +489,6 @@ impl Chat {
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert and insert a nostr event into the chat panel
|
||||
fn insert_message<E>(&mut self, event: E, scroll: bool, cx: &mut Context<Self>)
|
||||
where
|
||||
E: Into<RenderedMessage>,
|
||||
{
|
||||
let old_len = self.messages.len();
|
||||
|
||||
// Extend the messages list with the new events
|
||||
if self.messages.insert(Message::user(event)) {
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
|
||||
if scroll {
|
||||
self.list_state.scroll_to(ListOffset {
|
||||
item_ix: self.list_state.item_count(),
|
||||
offset_in_item: px(0.0),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert and insert a vector of nostr events into the chat panel
|
||||
fn insert_messages(&mut self, events: Vec<Event>, cx: &mut Context<Self>) {
|
||||
for event in events.into_iter() {
|
||||
self.insert_message(event, false, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn profile(&self, public_key: &PublicKey, cx: &Context<Self>) -> Profile {
|
||||
let registry = Registry::read_global(cx);
|
||||
registry.get_person(public_key, cx)
|
||||
@@ -416,73 +538,74 @@ impl Chat {
|
||||
}
|
||||
|
||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.uploading {
|
||||
return;
|
||||
}
|
||||
// Block the upload button to until current task is resolved
|
||||
self.uploading(true, cx);
|
||||
|
||||
// Get the user's configured NIP96 server
|
||||
let nip96_server = AppSettings::get_media_server(cx);
|
||||
|
||||
// Open native file dialog
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: None,
|
||||
});
|
||||
|
||||
let task = Tokio::spawn(cx, async move {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
if let Some(path) = paths.pop() {
|
||||
let file = fs::read(path).await?;
|
||||
let url = nip96_upload(nostr_client(), &nip96_server, file).await?;
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let mut paths = path.await.ok()?.ok()??;
|
||||
let path = paths.pop()?;
|
||||
|
||||
Ok(url)
|
||||
} else {
|
||||
Err(anyhow!("Path not found"))
|
||||
let upload = Tokio::spawn(cx, async move {
|
||||
let client = nostr_client();
|
||||
let file = fs::read(path).await.ok()?;
|
||||
let url = nip96_upload(client, &nip96_server, file).await.ok()?;
|
||||
|
||||
Some(url)
|
||||
});
|
||||
|
||||
if let Ok(task) = upload {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_uploading(true, cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
match Flatten::flatten(task.await.map_err(|e| e.into())) {
|
||||
Ok(Some(url)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.add_attachment(url, cx);
|
||||
this.set_uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Ok(None) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
window.push_notification("Failed to upload file", cx);
|
||||
this.set_uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
this.set_uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
Ok(None) => Err(anyhow!("User cancelled")),
|
||||
Err(e) => Err(anyhow!("File dialog error: {e}")),
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match Flatten::flatten(task.await.map_err(|e| e.into())) {
|
||||
Ok(Ok(url)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.add_attachment(url, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("User cancelled: {e}");
|
||||
this.update(cx, |this, cx| {
|
||||
this.uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
this.uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
|
||||
self.uploading = uploading;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn add_attachment(&mut self, url: Url, cx: &mut Context<Self>) {
|
||||
self.attachments.update(cx, |this, cx| {
|
||||
this.push(url);
|
||||
cx.notify();
|
||||
});
|
||||
self.uploading(false, cx);
|
||||
}
|
||||
|
||||
fn remove_attachment(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -494,9 +617,11 @@ impl Chat {
|
||||
});
|
||||
}
|
||||
|
||||
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.uploading = status;
|
||||
cx.notify();
|
||||
fn remove_all_attachments(&mut self, cx: &mut Context<Self>) {
|
||||
self.attachments.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn render_announcement(&mut self, ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||
@@ -525,6 +650,34 @@ impl Chat {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_warning(&mut self, ix: usize, content: String, cx: &mut Context<Self>) -> AnyElement {
|
||||
div()
|
||||
.id(ix)
|
||||
.relative()
|
||||
.w_full()
|
||||
.py_1()
|
||||
.px_3()
|
||||
.bg(cx.theme().warning_background)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.child(Avatar::new("brand/system.png").size(rems(2.)))
|
||||
.child(SharedString::from(content)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().warning_active),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_message_not_found(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
|
||||
div()
|
||||
.id(ix)
|
||||
@@ -576,7 +729,7 @@ impl Chat {
|
||||
.flex()
|
||||
.gap_3()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(Avatar::new(author.avatar_url(proxy)).size(rems(2.)))
|
||||
this.child(Avatar::new(author.avatar(proxy)).size(rems(2.)))
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
@@ -585,9 +738,7 @@ impl Chat {
|
||||
.flex_initial()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
@@ -597,7 +748,7 @@ impl Chat {
|
||||
.text_color(cx.theme().text)
|
||||
.child(author.display_name()),
|
||||
)
|
||||
.child(div().child(message.created_at.to_human_time()))
|
||||
.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))
|
||||
@@ -609,7 +760,23 @@ impl Chat {
|
||||
})
|
||||
.child(text)
|
||||
.when(is_sent_failed, |this| {
|
||||
this.child(self.render_message_reports(&id, cx))
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(self.render_message_reports(&id, cx))
|
||||
.child(
|
||||
Button::new(SharedString::from(id.to_hex()))
|
||||
.label(t!("common.resend"))
|
||||
.danger()
|
||||
.xsmall()
|
||||
.rounded()
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.resend_message(&id, window, cx);
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -617,14 +784,12 @@ impl Chat {
|
||||
.child(self.render_actions(&id, cx))
|
||||
.on_mouse_down(
|
||||
MouseButton::Middle,
|
||||
cx.listener(move |this, _event, _window, cx| {
|
||||
cx.listener(move |this, _, _window, cx| {
|
||||
this.copy_message(&id, cx);
|
||||
}),
|
||||
)
|
||||
.on_double_click(cx.listener({
|
||||
move |this, _event, _window, cx| {
|
||||
this.reply_to(&id, cx);
|
||||
}
|
||||
.on_double_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.reply_to(&id, cx);
|
||||
}))
|
||||
.hover(|this| this.bg(cx.theme().surface_background))
|
||||
.into_any_element()
|
||||
@@ -661,7 +826,7 @@ impl Chat {
|
||||
.w_full()
|
||||
.text_ellipsis()
|
||||
.line_clamp(1)
|
||||
.child(message.content.clone()),
|
||||
.child(SharedString::from(&message.content)),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.on_click({
|
||||
@@ -677,15 +842,17 @@ impl Chat {
|
||||
}
|
||||
|
||||
fn render_message_sent(&self, id: &EventId, _cx: &Context<Self>) -> impl IntoElement {
|
||||
div().id("").child(shared_t!("chat.sent")).when_some(
|
||||
self.sent_reports(id).cloned(),
|
||||
|this, reports| {
|
||||
div()
|
||||
.id(SharedString::from(id.to_hex()))
|
||||
.child(shared_t!("chat.sent"))
|
||||
.when_some(self.sent_reports(id).cloned(), |this, reports| {
|
||||
this.on_click(move |_e, window, cx| {
|
||||
let reports = reports.clone();
|
||||
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
this.title(shared_t!("chat.reports")).child(
|
||||
v_flex().pb_4().gap_4().children({
|
||||
this.show_close(true)
|
||||
.title(shared_t!("chat.reports"))
|
||||
.child(v_flex().pb_4().gap_4().children({
|
||||
let mut items = Vec::with_capacity(reports.len());
|
||||
|
||||
for report in reports.iter() {
|
||||
@@ -693,30 +860,29 @@ impl Chat {
|
||||
}
|
||||
|
||||
items
|
||||
}),
|
||||
)
|
||||
}))
|
||||
});
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_message_reports(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.id("")
|
||||
.gap_1()
|
||||
.id(SharedString::from(id.to_hex()))
|
||||
.gap_0p5()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.text_xs()
|
||||
.italic()
|
||||
.child(Icon::new(IconName::Info).small())
|
||||
.child(Icon::new(IconName::Info).xsmall())
|
||||
.child(shared_t!("chat.sent_failed"))
|
||||
.when_some(self.sent_reports(id).cloned(), |this, reports| {
|
||||
this.on_click(move |_e, window, cx| {
|
||||
let reports = reports.clone();
|
||||
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
this.title(shared_t!("chat.reports")).child(
|
||||
v_flex().pb_4().gap_4().children({
|
||||
this.show_close(true)
|
||||
.title(shared_t!("chat.reports"))
|
||||
.child(v_flex().gap_4().pb_4().w_full().children({
|
||||
let mut items = Vec::with_capacity(reports.len());
|
||||
|
||||
for report in reports.iter() {
|
||||
@@ -724,8 +890,7 @@ impl Chat {
|
||||
}
|
||||
|
||||
items
|
||||
}),
|
||||
)
|
||||
}))
|
||||
});
|
||||
})
|
||||
})
|
||||
@@ -735,10 +900,11 @@ impl Chat {
|
||||
let registry = Registry::read_global(cx);
|
||||
let profile = registry.get_person(&report.receiver, cx);
|
||||
let name = profile.display_name();
|
||||
let avatar = profile.avatar_url(true);
|
||||
let avatar = profile.avatar(true);
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
@@ -752,7 +918,7 @@ impl Chat {
|
||||
.child(name.clone()),
|
||||
),
|
||||
)
|
||||
.when(report.nip17_relays_not_found, |this| {
|
||||
.when(report.relays_not_found, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_wrap()
|
||||
@@ -773,7 +939,7 @@ impl Chat {
|
||||
),
|
||||
)
|
||||
})
|
||||
.when_some(report.local_error.clone(), |this, error| {
|
||||
.when_some(report.error.clone(), |this, error| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_wrap()
|
||||
@@ -788,38 +954,36 @@ impl Chat {
|
||||
.child(div().flex_1().w_full().text_center().child(error)),
|
||||
)
|
||||
})
|
||||
.when_some(report.output.clone(), |this, output| {
|
||||
.when_some(report.status.clone(), |this, output| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.w_full()
|
||||
.children({
|
||||
let mut items = Vec::with_capacity(output.failed.len());
|
||||
|
||||
for (url, msg) in output.failed.into_iter() {
|
||||
items.push(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.justify_between()
|
||||
.text_sm()
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.py_1()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.py_0p5()
|
||||
.px_2()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded_sm()
|
||||
.child(url.to_string()),
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(url.to_string())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.py_0p5()
|
||||
.px_2()
|
||||
.bg(cx.theme().danger_background)
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.rounded_sm()
|
||||
.child(msg.to_string()),
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(msg.to_string())),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -831,27 +995,25 @@ impl Chat {
|
||||
|
||||
for url in output.success.into_iter() {
|
||||
items.push(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.justify_between()
|
||||
.text_sm()
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.py_1()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.py_0p5()
|
||||
.px_2()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded_sm()
|
||||
.child(url.to_string()),
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(url.to_string())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.py_0p5()
|
||||
.px_2()
|
||||
.bg(cx.theme().secondary_background)
|
||||
.text_sm()
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.rounded_sm()
|
||||
.line_height(relative(1.25))
|
||||
.child(shared_t!("chat.sent_success")),
|
||||
),
|
||||
)
|
||||
@@ -875,31 +1037,6 @@ impl Chat {
|
||||
}
|
||||
|
||||
fn render_actions(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
||||
let groups = vec![
|
||||
Button::new("reply")
|
||||
.icon(IconName::Reply)
|
||||
.tooltip(t!("chat.reply_button"))
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click({
|
||||
let id = id.to_owned();
|
||||
cx.listener(move |this, _event, _window, cx| {
|
||||
this.reply_to(&id, cx);
|
||||
})
|
||||
}),
|
||||
Button::new("copy")
|
||||
.icon(IconName::Copy)
|
||||
.tooltip(t!("chat.copy_message_button"))
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click({
|
||||
let id = id.to_owned();
|
||||
cx.listener(move |this, _event, _window, cx| {
|
||||
this.copy_message(&id, cx);
|
||||
})
|
||||
}),
|
||||
];
|
||||
|
||||
h_flex()
|
||||
.p_0p5()
|
||||
.gap_1()
|
||||
@@ -912,20 +1049,55 @@ impl Chat {
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().background)
|
||||
.children(groups)
|
||||
.child(
|
||||
Button::new("reply")
|
||||
.icon(IconName::Reply)
|
||||
.tooltip(t!("chat.reply_button"))
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click({
|
||||
let id = id.to_owned();
|
||||
cx.listener(move |this, _event, _window, cx| {
|
||||
this.reply_to(&id, cx);
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("copy")
|
||||
.icon(IconName::Copy)
|
||||
.tooltip(t!("chat.copy_message_button"))
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click({
|
||||
let id = id.to_owned();
|
||||
cx.listener(move |this, _event, _window, cx| {
|
||||
this.copy_message(&id, cx);
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
||||
.child(
|
||||
Button::new("seen-on")
|
||||
.icon(IconName::Ellipsis)
|
||||
.small()
|
||||
.ghost()
|
||||
.popup_menu({
|
||||
let id = id.to_owned();
|
||||
move |this, _window, _cx| {
|
||||
this.menu(t!("common.seen_on"), Box::new(SeenOn(id)))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.group_hover("", |this| this.visible())
|
||||
}
|
||||
|
||||
fn render_attachment(&self, url: &Url, cx: &Context<Self>) -> impl IntoElement {
|
||||
let url = url.clone();
|
||||
let path: SharedString = url.to_string().into();
|
||||
|
||||
div()
|
||||
.id("")
|
||||
.id(SharedString::from(url.to_string()))
|
||||
.relative()
|
||||
.w_16()
|
||||
.child(
|
||||
img(path.clone())
|
||||
img(SharedUri::from(url.to_string()))
|
||||
.size_16()
|
||||
.shadow_lg()
|
||||
.rounded(cx.theme().radius)
|
||||
@@ -944,9 +1116,12 @@ impl Chat {
|
||||
.bg(red())
|
||||
.child(Icon::new(IconName::Close).size_2().text_color(white())),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.remove_attachment(&url, window, cx);
|
||||
}))
|
||||
.on_click({
|
||||
let url = url.clone();
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.remove_attachment(&url, window, cx);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn render_attachment_list(
|
||||
@@ -1011,7 +1186,7 @@ impl Chat {
|
||||
.text_sm()
|
||||
.text_ellipsis()
|
||||
.line_clamp(1)
|
||||
.child(text.content.clone()),
|
||||
.child(SharedString::from(&text.content)),
|
||||
)
|
||||
} else {
|
||||
div()
|
||||
@@ -1088,6 +1263,62 @@ impl Chat {
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
|
||||
fn on_open_seen_on(&mut self, ev: &SeenOn, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let id = ev.0;
|
||||
|
||||
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let app_state = app_state();
|
||||
let mut relays: Vec<RelayUrl> = vec![];
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.event(id)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
if let Some(Ok(id)) = event.tags.identifier().map(EventId::parse) {
|
||||
if let Some(urls) = app_state.seen_on_relays.read().await.get(&id).cloned() {
|
||||
relays.extend(urls);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(relays)
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
if let Ok(urls) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
this.title(shared_t!("common.seen_on")).child(
|
||||
v_flex().pb_4().gap_2().children({
|
||||
let mut items = Vec::with_capacity(urls.len());
|
||||
|
||||
for url in urls.clone().into_iter() {
|
||||
items.push(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.font_semibold()
|
||||
.text_xs()
|
||||
.child(url.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
}),
|
||||
)
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Chat {
|
||||
@@ -1134,6 +1365,7 @@ impl Focusable for Chat {
|
||||
impl Render for Chat {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.on_action(cx.listener(Self::on_open_seen_on))
|
||||
.image_cache(self.image_cache.clone())
|
||||
.size_full()
|
||||
.child(
|
||||
@@ -1151,6 +1383,9 @@ impl Render for Chat {
|
||||
|
||||
this.render_message(ix, rendered, text, cx)
|
||||
}
|
||||
Message::Warning(content, _) => {
|
||||
this.render_warning(ix, content.to_owned(), cx)
|
||||
}
|
||||
Message::System(_) => this.render_announcement(ix, cx),
|
||||
}
|
||||
} else {
|
||||
@@ -1168,9 +1403,7 @@ impl Render for Chat {
|
||||
.px_3()
|
||||
.py_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.children(self.render_attachment_list(window, cx))
|
||||
.children(self.render_reply_list(window, cx))
|
||||
@@ -1189,10 +1422,10 @@ impl Render for Chat {
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::Upload)
|
||||
.loading(self.uploading)
|
||||
.disabled(self.uploading)
|
||||
.ghost()
|
||||
.large()
|
||||
.disabled(self.uploading)
|
||||
.loading(self.uploading)
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
|
||||
@@ -2,7 +2,7 @@ use gpui::{
|
||||
div, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Window,
|
||||
};
|
||||
use i18n::t;
|
||||
use i18n::{shared_t, t};
|
||||
use theme::ActiveTheme;
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::{v_flex, Sizable};
|
||||
@@ -41,7 +41,7 @@ impl Render for Subject {
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::new(t!("subject.title"))),
|
||||
.child(shared_t!("subject.title")),
|
||||
)
|
||||
.child(TextInput::new(&self.input).small())
|
||||
.child(
|
||||
@@ -49,7 +49,7 @@ impl Render for Subject {
|
||||
.text_xs()
|
||||
.italic()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(SharedString::new(t!("subject.help_text"))),
|
||||
.child(shared_t!("subject.help_text")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+260
-291
@@ -2,28 +2,29 @@ use std::ops::Range;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::display::{ReadableProfile, TextUtils};
|
||||
use common::display::{RenderedProfile, TextUtils};
|
||||
use common::nip05::nip05_profile;
|
||||
use global::constants::BOOTSTRAP_RELAYS;
|
||||
use global::nostr_client;
|
||||
use global::{app_state, nostr_client};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, relative, rems, uniform_list, AppContext, Context, Entity, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
|
||||
Subscription, Task, Window,
|
||||
div, px, relative, rems, uniform_list, App, AppContext, Context, Entity, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, RetainAllImageCache, SharedString,
|
||||
StatefulInteractiveElement, Styled, Subscription, Task, Window,
|
||||
};
|
||||
use i18n::t;
|
||||
use itertools::Itertools;
|
||||
use gpui_tokio::Tokio;
|
||||
use i18n::{shared_t, t};
|
||||
use nostr_sdk::prelude::*;
|
||||
use registry::room::{Room, RoomKind};
|
||||
use registry::room::Room;
|
||||
use registry::Registry;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::Timer;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonRounded, ButtonVariants};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::notification::Notification;
|
||||
use ui::{h_flex, v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
@@ -34,22 +35,46 @@ pub fn compose_button() -> impl IntoElement {
|
||||
.ghost_alt()
|
||||
.cta()
|
||||
.small()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.rounded()
|
||||
.on_click(move |_, window, cx| {
|
||||
let compose = cx.new(|cx| Compose::new(window, cx));
|
||||
let title = SharedString::new(t!("sidebar.direct_messages"));
|
||||
let weak_view = compose.downgrade();
|
||||
|
||||
window.open_modal(cx, move |modal, _window, _cx| {
|
||||
modal.title(title.clone()).child(compose.clone())
|
||||
window.open_modal(cx, move |modal, _window, cx| {
|
||||
let weak_view = weak_view.clone();
|
||||
let label = if compose.read(cx).selected(cx).len() > 1 {
|
||||
shared_t!("compose.create_group_dm_button")
|
||||
} else {
|
||||
shared_t!("compose.create_dm_button")
|
||||
};
|
||||
|
||||
modal
|
||||
.alert()
|
||||
.overlay_closable(true)
|
||||
.keyboard(true)
|
||||
.show_close(true)
|
||||
.button_props(ModalButtonProps::default().ok_text(label))
|
||||
.title(shared_t!("sidebar.direct_messages"))
|
||||
.child(compose.clone())
|
||||
.on_ok(move |_, window, cx| {
|
||||
weak_view
|
||||
.update(cx, |this, cx| {
|
||||
this.submit(window, cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
// false to prevent the modal from closing
|
||||
false
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct Contact {
|
||||
public_key: PublicKey,
|
||||
select: bool,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl AsRef<PublicKey> for Contact {
|
||||
@@ -62,12 +87,12 @@ impl Contact {
|
||||
pub fn new(public_key: PublicKey) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
select: false,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select(mut self) -> Self {
|
||||
self.select = true;
|
||||
pub fn selected(mut self) -> Self {
|
||||
self.selected = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -75,188 +100,209 @@ impl Contact {
|
||||
pub struct Compose {
|
||||
/// Input for the room's subject
|
||||
title_input: Entity<InputState>,
|
||||
|
||||
/// Input for the room's members
|
||||
user_input: Entity<InputState>,
|
||||
/// The current user's contacts
|
||||
contacts: Vec<Entity<Contact>>,
|
||||
/// Input error message
|
||||
|
||||
/// User's contacts
|
||||
contacts: Entity<Vec<Contact>>,
|
||||
|
||||
/// Error message
|
||||
error_message: Entity<Option<SharedString>>,
|
||||
adding: bool,
|
||||
submitting: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
image_cache: Entity<RetainAllImageCache>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl Compose {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self {
|
||||
let contacts = cx.new(|_| vec![]);
|
||||
let error_message = cx.new(|_| None);
|
||||
|
||||
let user_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("compose.placeholder_npub")));
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("npub or nprofile..."));
|
||||
|
||||
let title_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("compose.placeholder_title")));
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("Family...(Optional)"));
|
||||
|
||||
let error_message = cx.new(|_| None);
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
// Handle Enter event for user input
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&user_input,
|
||||
window,
|
||||
move |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add_and_select_contact(window, cx)
|
||||
};
|
||||
},
|
||||
));
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
let get_contacts: Task<Result<Vec<Contact>, Error>> = cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let profiles = client.database().contacts(public_key).await?;
|
||||
let contacts = profiles
|
||||
let contacts: Vec<Contact> = profiles
|
||||
.into_iter()
|
||||
.map(|profile| Contact::new(profile.public_key()))
|
||||
.collect_vec();
|
||||
.collect();
|
||||
|
||||
Ok(contacts)
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match get_contacts.await {
|
||||
Ok(contacts) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.extend_contacts(contacts, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
tasks.push(
|
||||
// Load all contacts
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match get_contacts.await {
|
||||
Ok(contacts) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.extend_contacts(contacts, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Clear the image cache when sidebar is closed
|
||||
cx.on_release_in(window, move |this, window, cx| {
|
||||
this.image_cache.update(cx, |this, cx| {
|
||||
this.clear(window, cx);
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Handle Enter event for user input
|
||||
cx.subscribe_in(
|
||||
&user_input,
|
||||
window,
|
||||
move |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add_and_select_contact(window, cx)
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Self {
|
||||
adding: false,
|
||||
submitting: false,
|
||||
contacts: vec![],
|
||||
title_input,
|
||||
user_input,
|
||||
error_message,
|
||||
subscriptions,
|
||||
contacts,
|
||||
image_cache: RetainAllImageCache::new(cx),
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_metadata(client: &Client, public_key: PublicKey) -> Result<(), Error> {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
async fn request_metadata(public_key: PublicKey) -> Result<(), Error> {
|
||||
let client = nostr_client();
|
||||
let app_state = app_state();
|
||||
let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList];
|
||||
let filter = Filter::new().author(public_key).kinds(kinds).limit(10);
|
||||
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, app_state.auto_close_opts)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let public_keys: Vec<PublicKey> = self.selected(cx);
|
||||
|
||||
if public_keys.is_empty() {
|
||||
self.set_error(Some(t!("compose.receiver_required").into()), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
// Show loading spinner
|
||||
self.set_submitting(true, cx);
|
||||
|
||||
// Convert selected pubkeys into Nostr tags
|
||||
let mut tag_list: Vec<Tag> = public_keys.iter().map(|pk| Tag::public_key(*pk)).collect();
|
||||
|
||||
// Add subject if it is present
|
||||
if !self.title_input.read(cx).value().is_empty() {
|
||||
tag_list.push(Tag::custom(
|
||||
TagKind::Subject,
|
||||
vec![self.title_input.read(cx).value().to_string()],
|
||||
));
|
||||
}
|
||||
|
||||
let event: Task<Result<Room, Error>> = cx.background_spawn(async move {
|
||||
let signer = nostr_client().signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let room = EventBuilder::private_msg_rumor(public_keys[0], "")
|
||||
.tags(Tags::from_list(tag_list))
|
||||
.build(public_key)
|
||||
.sign(&Keys::generate())
|
||||
.await
|
||||
.map(|event| Room::new(&event).kind(RoomKind::Ongoing))?;
|
||||
|
||||
Ok(room)
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match event.await {
|
||||
Ok(room) => {
|
||||
cx.update(|window, cx| {
|
||||
let registry = Registry::global(cx);
|
||||
// Reset local state
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_submitting(false, cx);
|
||||
})
|
||||
.ok();
|
||||
// Create and insert the new room into the registry
|
||||
registry.update(cx, |this, cx| {
|
||||
this.push_room(cx.new(|_| room), cx);
|
||||
});
|
||||
// Close the current modal
|
||||
window.close_modal(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(Some(e.to_string().into()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn extend_contacts<I>(&mut self, contacts: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: IntoIterator<Item = Contact>,
|
||||
{
|
||||
self.contacts
|
||||
.extend(contacts.into_iter().map(|contact| cx.new(|_| contact)));
|
||||
cx.notify();
|
||||
self.contacts.update(cx, |this, cx| {
|
||||
this.extend(contacts);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn push_contact(&mut self, contact: Contact, cx: &mut Context<Self>) {
|
||||
if !self
|
||||
.contacts
|
||||
.iter()
|
||||
.any(|e| e.read(cx).public_key == contact.public_key)
|
||||
{
|
||||
self.contacts.insert(0, cx.new(|_| contact));
|
||||
cx.notify();
|
||||
fn push_contact(&mut self, contact: Contact, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let pk = contact.public_key;
|
||||
|
||||
if !self.contacts.read(cx).iter().any(|c| c.public_key == pk) {
|
||||
self._tasks.push(cx.background_spawn(async move {
|
||||
Self::request_metadata(pk).await.ok();
|
||||
}));
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.insert(0, contact);
|
||||
cx.notify();
|
||||
});
|
||||
this.user_input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
self.set_error(Some(t!("compose.contact_existed").into()), cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn selected(&self, cx: &Context<Self>) -> Vec<PublicKey> {
|
||||
fn select_contact(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
self.contacts.update(cx, |this, cx| {
|
||||
if let Some(contact) = this.iter_mut().find(|c| c.public_key == public_key) {
|
||||
contact.selected = true;
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn add_and_select_contact(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let content = self.user_input.read(cx).value().to_string();
|
||||
|
||||
// Show loading indicator in the input
|
||||
self.user_input.update(cx, |this, cx| {
|
||||
this.set_loading(true, cx);
|
||||
});
|
||||
|
||||
if let Ok(public_key) = content.to_public_key() {
|
||||
let contact = Contact::new(public_key).selected();
|
||||
self.push_contact(contact, window, cx);
|
||||
} else if content.contains("@") {
|
||||
let task = Tokio::spawn(cx, async move {
|
||||
if let Ok(profile) = nip05_profile(&content).await {
|
||||
let public_key = profile.public_key;
|
||||
let contact = Contact::new(public_key).selected();
|
||||
|
||||
Ok(contact)
|
||||
} else {
|
||||
Err(anyhow!("Not found"))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(Ok(contact)) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.push_contact(contact, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(Some(e.to_string().into()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Tokio error: {e}");
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn selected(&self, cx: &App) -> Vec<PublicKey> {
|
||||
self.contacts
|
||||
.read(cx)
|
||||
.iter()
|
||||
.filter_map(|contact| {
|
||||
if contact.read(cx).select {
|
||||
Some(contact.read(cx).public_key)
|
||||
if contact.selected {
|
||||
Some(contact.public_key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -264,84 +310,49 @@ impl Compose {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn add_and_select_contact(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let content = self.user_input.read(cx).value().to_string();
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let registry = Registry::global(cx);
|
||||
let public_keys: Vec<PublicKey> = self.selected(cx);
|
||||
|
||||
// Prevent multiple requests
|
||||
self.set_adding(true, cx);
|
||||
|
||||
// Show loading indicator in the input
|
||||
self.user_input.update(cx, |this, cx| {
|
||||
this.set_loading(true, cx);
|
||||
});
|
||||
|
||||
let task: Task<Result<Contact, Error>> = if content.contains("@") {
|
||||
cx.background_spawn(async move {
|
||||
let (tx, rx) = oneshot::channel::<Option<Nip05Profile>>();
|
||||
|
||||
nostr_sdk::async_utility::task::spawn(async move {
|
||||
let profile = nip05_profile(&content).await.ok();
|
||||
tx.send(profile).ok();
|
||||
});
|
||||
|
||||
if let Ok(Some(profile)) = rx.await {
|
||||
let client = nostr_client();
|
||||
let public_key = profile.public_key;
|
||||
let contact = Contact::new(public_key).select();
|
||||
|
||||
Self::request_metadata(client, public_key).await?;
|
||||
|
||||
Ok(contact)
|
||||
} else {
|
||||
Err(anyhow!(t!("common.not_found")))
|
||||
}
|
||||
})
|
||||
} else if let Ok(public_key) = content.to_public_key() {
|
||||
cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let contact = Contact::new(public_key).select();
|
||||
|
||||
Self::request_metadata(client, public_key).await?;
|
||||
|
||||
Ok(contact)
|
||||
})
|
||||
} else {
|
||||
self.set_error(Some(t!("common.pubkey_invalid").into()), cx);
|
||||
if !self.user_input.read(cx).value().is_empty() {
|
||||
self.add_and_select_contact(window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(contact) => {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.push_contact(contact, cx);
|
||||
this.set_adding(false, cx);
|
||||
this.user_input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(Some(e.to_string().into()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
if public_keys.is_empty() {
|
||||
self.set_error(Some(t!("compose.receiver_required").into()), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
// Convert selected pubkeys into Nostr tags
|
||||
let mut tags: Tags = Tags::from_list(
|
||||
public_keys
|
||||
.iter()
|
||||
.map(|pubkey| Tag::public_key(pubkey.to_owned()))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
// Add subject if it is present
|
||||
if !self.title_input.read(cx).value().is_empty() {
|
||||
tags.push(Tag::custom(
|
||||
TagKind::Subject,
|
||||
vec![self.title_input.read(cx).value().to_string()],
|
||||
));
|
||||
}
|
||||
|
||||
// Create a new room
|
||||
let room = Room::new(public_keys[0], tags, cx);
|
||||
|
||||
// Insert the new room into the registry
|
||||
registry.update(cx, |this, cx| {
|
||||
this.push_room(cx.new(|_| room), cx);
|
||||
});
|
||||
|
||||
// Close the current modal
|
||||
window.close_modal(cx);
|
||||
}
|
||||
|
||||
fn set_error(&mut self, error: impl Into<Option<SharedString>>, cx: &mut Context<Self>) {
|
||||
if self.adding {
|
||||
self.set_adding(false, cx);
|
||||
}
|
||||
|
||||
// Unlock the user input
|
||||
self.user_input.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
@@ -364,48 +375,35 @@ impl Compose {
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_adding(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.adding = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.submitting = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn list_items(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
|
||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||
let registry = Registry::read_global(cx);
|
||||
let mut items = Vec::with_capacity(self.contacts.len());
|
||||
let mut items = Vec::with_capacity(self.contacts.read(cx).len());
|
||||
|
||||
for ix in range {
|
||||
let Some(entity) = self.contacts.get(ix).cloned() else {
|
||||
let Some(contact) = self.contacts.read(cx).get(ix) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let public_key = entity.read(cx).as_ref();
|
||||
let profile = registry.get_person(public_key, cx);
|
||||
let selected = entity.read(cx).select;
|
||||
let public_key = contact.public_key;
|
||||
let profile = registry.get_person(&public_key, cx);
|
||||
|
||||
items.push(
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_1()
|
||||
.h_9()
|
||||
.px_2()
|
||||
.h_11()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.text_sm()
|
||||
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(1.75)))
|
||||
.child(Avatar::new(profile.avatar(proxy)).size(rems(1.75)))
|
||||
.child(profile.display_name()),
|
||||
)
|
||||
.when(selected, |this| {
|
||||
.when(contact.selected, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::CheckCircleFill)
|
||||
.small()
|
||||
@@ -413,11 +411,8 @@ impl Compose {
|
||||
)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.on_click(cx.listener(move |_this, _event, _window, cx| {
|
||||
entity.update(cx, |this, cx| {
|
||||
this.select = !this.select;
|
||||
cx.notify();
|
||||
});
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.select_contact(public_key, cx);
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -428,24 +423,18 @@ impl Compose {
|
||||
|
||||
impl Render for Compose {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let label = if self.submitting {
|
||||
t!("compose.creating_dm_button")
|
||||
} else if self.selected(cx).len() > 1 {
|
||||
t!("compose.create_group_dm_button")
|
||||
} else {
|
||||
t!("compose.create_dm_button")
|
||||
};
|
||||
|
||||
let error = self.error_message.read(cx).as_ref();
|
||||
let loading = self.user_input.read(cx).loading(cx);
|
||||
let contacts = self.contacts.read(cx);
|
||||
|
||||
v_flex()
|
||||
.mb_4()
|
||||
.image_cache(self.image_cache.clone())
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::new(t!("compose.description"))),
|
||||
.child(shared_t!("compose.description")),
|
||||
)
|
||||
.when_some(error, |this, msg| {
|
||||
this.child(
|
||||
@@ -466,13 +455,13 @@ impl Render for Compose {
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(SharedString::new(t!("compose.subject_label"))),
|
||||
.child(shared_t!("compose.subject_label")),
|
||||
)
|
||||
.child(TextInput::new(&self.title_input).small().appearance(false)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.my_1()
|
||||
.pt_1()
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
@@ -481,22 +470,18 @@ impl Render for Compose {
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(SharedString::new(t!("compose.to_label"))),
|
||||
.child(shared_t!("compose.to_label")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
TextInput::new(&self.user_input)
|
||||
.small()
|
||||
.disabled(self.adding),
|
||||
)
|
||||
.child(
|
||||
TextInput::new(&self.user_input)
|
||||
.small()
|
||||
.disabled(loading)
|
||||
.suffix(
|
||||
Button::new("add")
|
||||
.icon(IconName::PlusCircleFill)
|
||||
.ghost()
|
||||
.loading(self.adding)
|
||||
.disabled(self.adding)
|
||||
.transparent()
|
||||
.small()
|
||||
.disabled(loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.add_and_select_contact(window, cx);
|
||||
})),
|
||||
@@ -504,7 +489,7 @@ impl Render for Compose {
|
||||
),
|
||||
)
|
||||
.map(|this| {
|
||||
if self.contacts.is_empty() {
|
||||
if contacts.is_empty() {
|
||||
this.child(
|
||||
v_flex()
|
||||
.h_24()
|
||||
@@ -512,48 +497,32 @@ impl Render for Compose {
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.text_xs()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child(SharedString::new(t!(
|
||||
"compose.no_contacts_message"
|
||||
))),
|
||||
.child(shared_t!("compose.no_contacts_message")),
|
||||
)
|
||||
.child(
|
||||
div().text_xs().text_color(cx.theme().text_muted).child(
|
||||
SharedString::new(t!(
|
||||
"compose.no_contacts_description"
|
||||
)),
|
||||
),
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(shared_t!("compose.no_contacts_description")),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
uniform_list(
|
||||
"contacts",
|
||||
self.contacts.len(),
|
||||
contacts.len(),
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.list_items(range, cx)
|
||||
}),
|
||||
)
|
||||
.min_h(px(300.)),
|
||||
.h(px(300.)),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("create_dm_btn")
|
||||
.label(label)
|
||||
.primary()
|
||||
.small()
|
||||
.w_full()
|
||||
.loading(self.submitting)
|
||||
.disabled(self.submitting || self.adding)
|
||||
.on_click(cx.listener(move |this, _event, window, cx| {
|
||||
this.submit(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use gpui::{
|
||||
div, img, App, AppContext, Context, Entity, Flatten, IntoElement, ParentElement,
|
||||
PathPromptOptions, Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use i18n::t;
|
||||
use i18n::{shared_t, t};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use smol::fs;
|
||||
@@ -164,7 +164,7 @@ impl EditProfile {
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Option<Event>, Error>> {
|
||||
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Option<Profile>, Error>> {
|
||||
let avatar = self.avatar_input.read(cx).value().to_string();
|
||||
let name = self.name_input.read(cx).value().to_string();
|
||||
let bio = self.bio_input.read(cx).value().to_string();
|
||||
@@ -189,7 +189,14 @@ impl EditProfile {
|
||||
cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let output = client.set_metadata(&new_metadata).await?;
|
||||
let event = client.database().event_by_id(&output.val).await?;
|
||||
let event = client
|
||||
.database()
|
||||
.event_by_id(&output.val)
|
||||
.await?
|
||||
.map(|event| {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
Profile::new(event.pubkey, metadata)
|
||||
});
|
||||
|
||||
Ok(event)
|
||||
})
|
||||
@@ -253,7 +260,7 @@ impl Render for EditProfile {
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::new(t!("profile.label_name")))
|
||||
.child(shared_t!("profile.label_name"))
|
||||
.child(TextInput::new(&self.name_input).small()),
|
||||
)
|
||||
.child(
|
||||
@@ -262,7 +269,7 @@ impl Render for EditProfile {
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::new(t!("profile.label_website")))
|
||||
.child(shared_t!("profile.label_website"))
|
||||
.child(TextInput::new(&self.website_input).small()),
|
||||
)
|
||||
.child(
|
||||
@@ -271,7 +278,7 @@ impl Render for EditProfile {
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::new(t!("profile.label_bio")))
|
||||
.child(shared_t!("profile.label_bio"))
|
||||
.child(TextInput::new(&self.bio_input).small()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use client_keys::ClientKeys;
|
||||
use common::handle_auth::CoopAuthUrlHandler;
|
||||
use global::constants::{ACCOUNT_IDENTIFIER, BUNKER_TIMEOUT};
|
||||
use global::nostr_client;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
@@ -19,6 +18,8 @@ use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::popup_menu::PopupMenu;
|
||||
use ui::{v_flex, ContextModal, Disableable, Sizable, StyledExt};
|
||||
|
||||
use crate::actions::CoopAuthUrlHandler;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
|
||||
Login::new(window, cx)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use settings::AppSettings;
|
||||
use smol::fs;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonRounded, ButtonVariants};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
@@ -352,7 +352,7 @@ impl Render for NewAccount {
|
||||
.label(t!("common.upload"))
|
||||
.ghost()
|
||||
.small()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.rounded()
|
||||
.disabled(self.submitting || self.uploading)
|
||||
.loading(self.uploading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use common::display::ReadableProfile;
|
||||
use common::display::RenderedProfile;
|
||||
use gpui::http_client::Url;
|
||||
use gpui::{
|
||||
div, px, relative, rems, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
|
||||
@@ -10,7 +10,7 @@ use registry::Registry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonRounded, ButtonVariants};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::switch::Switch;
|
||||
@@ -41,28 +41,28 @@ impl Preferences {
|
||||
fn open_edit_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let view = edit_profile::init(window, cx);
|
||||
let weak_view = view.downgrade();
|
||||
let title = SharedString::new(t!("profile.title"));
|
||||
|
||||
window.open_modal(cx, move |modal, _window, _cx| {
|
||||
let weak_view = weak_view.clone();
|
||||
|
||||
modal
|
||||
.confirm()
|
||||
.title(title.clone())
|
||||
.title(shared_t!("profile.title"))
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
|
||||
.on_ok(move |_, window, cx| {
|
||||
weak_view
|
||||
.update(cx, |this, cx| {
|
||||
let set_metadata = this.set_metadata(cx);
|
||||
let registry = Registry::global(cx);
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
match set_metadata.await {
|
||||
Ok(event) => {
|
||||
if let Some(event) = event {
|
||||
Ok(profile) => {
|
||||
if let Some(profile) = profile {
|
||||
cx.update(|_, cx| {
|
||||
Registry::global(cx).update(cx, |this, cx| {
|
||||
this.insert_or_update_person(event, cx);
|
||||
registry.update(cx, |this, cx| {
|
||||
this.insert_or_update_person(profile, cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
@@ -141,7 +141,7 @@ impl Render for Preferences {
|
||||
h_flex()
|
||||
.id("user")
|
||||
.gap_2()
|
||||
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(2.4)))
|
||||
.child(Avatar::new(profile.avatar(proxy)).size(rems(2.4)))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
@@ -169,7 +169,7 @@ impl Render for Preferences {
|
||||
.label("Messaging Relays")
|
||||
.xsmall()
|
||||
.ghost_alt()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.open_relays(window, cx);
|
||||
})),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use common::display::{shorten_pubkey, ReadableProfile, ReadableTimestamp};
|
||||
use common::display::{shorten_pubkey, RenderedProfile, RenderedTimestamp};
|
||||
use common::nip05::nip05_verify;
|
||||
use global::constants::BOOTSTRAP_RELAYS;
|
||||
use global::nostr_client;
|
||||
@@ -17,7 +17,7 @@ use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonRounded, ButtonVariants};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
@@ -29,10 +29,9 @@ pub struct Screening {
|
||||
profile: Profile,
|
||||
verified: bool,
|
||||
followed: bool,
|
||||
dm_relays: Option<bool>,
|
||||
last_active: Option<Timestamp>,
|
||||
mutual_contacts: Vec<Profile>,
|
||||
_tasks: SmallVec<[Task<()>; 4]>,
|
||||
_tasks: SmallVec<[Task<()>; 3]>,
|
||||
}
|
||||
|
||||
impl Screening {
|
||||
@@ -83,24 +82,6 @@ impl Screening {
|
||||
activity
|
||||
});
|
||||
|
||||
let relay_check = cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let mut relay = false;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Ok(mut stream) = client.stream_events(filter, Duration::from_secs(2)).await {
|
||||
while stream.next().await.is_some() {
|
||||
relay = true
|
||||
}
|
||||
}
|
||||
|
||||
relay
|
||||
});
|
||||
|
||||
let addr_check = if let Some(address) = profile.metadata().nip05 {
|
||||
Some(Tokio::spawn(cx, async move {
|
||||
nip05_verify(public_key, &address).await.unwrap_or(false)
|
||||
@@ -136,19 +117,6 @@ impl Screening {
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Run the relay check in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let relay = relay_check.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.dm_relays = Some(relay);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Run the NIP-05 verification in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
@@ -168,7 +136,6 @@ impl Screening {
|
||||
profile,
|
||||
verified: false,
|
||||
followed: false,
|
||||
dm_relays: None,
|
||||
last_active: None,
|
||||
mutual_contacts: vec![],
|
||||
_tasks: tasks,
|
||||
@@ -235,9 +202,7 @@ impl Screening {
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().elevated_surface_background)
|
||||
})
|
||||
.child(
|
||||
Avatar::new(contact.avatar_url(true)).size(rems(1.75)),
|
||||
)
|
||||
.child(Avatar::new(contact.avatar(true)).size(rems(1.75)))
|
||||
.child(contact.display_name()),
|
||||
);
|
||||
}
|
||||
@@ -267,7 +232,7 @@ impl Render for Screening {
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(Avatar::new(self.profile.avatar_url(proxy)).size(rems(4.)))
|
||||
.child(Avatar::new(self.profile.avatar(proxy)).size(rems(4.)))
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
@@ -301,7 +266,7 @@ impl Render for Screening {
|
||||
.label(t!("profile.njump"))
|
||||
.secondary()
|
||||
.small()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.open_njump(window, cx);
|
||||
})),
|
||||
@@ -311,7 +276,7 @@ impl Render for Screening {
|
||||
.tooltip(t!("screening.report"))
|
||||
.icon(IconName::Report)
|
||||
.danger()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.report(window, cx);
|
||||
})),
|
||||
@@ -363,7 +328,7 @@ impl Render for Screening {
|
||||
.icon(IconName::Info)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.rounded()
|
||||
.tooltip(t!("screening.active_tooltip")),
|
||||
),
|
||||
)
|
||||
@@ -435,7 +400,7 @@ impl Render for Screening {
|
||||
.icon(IconName::Info)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.rounded()
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.mutual_contacts(window, cx);
|
||||
@@ -456,37 +421,6 @@ impl Render for Screening {
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(self.dm_relays, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.child({
|
||||
if self.dm_relays == Some(true) {
|
||||
shared_t!("screening.relay_found")
|
||||
} else {
|
||||
shared_t!("screening.relay_empty")
|
||||
}
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if self.dm_relays == Some(true) {
|
||||
shared_t!("screening.relay_found_desc")
|
||||
} else {
|
||||
shared_t!("screening.relay_empty_desc")
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use global::constants::NIP17_RELAYS;
|
||||
use global::{css, nostr_client};
|
||||
use global::{app_state, nostr_client};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
|
||||
@@ -14,7 +14,7 @@ use nostr_sdk::prelude::*;
|
||||
use registry::Registry;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonRounded, ButtonVariants};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable, StyledExt};
|
||||
@@ -33,7 +33,7 @@ where
|
||||
.label(label)
|
||||
.warning()
|
||||
.xsmall()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.rounded()
|
||||
.on_click(move |_, window, cx| {
|
||||
let view = cx.new(|cx| SetupRelay::new(Kind::InboxRelays, window, cx));
|
||||
let weak_view = view.downgrade();
|
||||
@@ -218,7 +218,7 @@ impl SetupRelay {
|
||||
}
|
||||
|
||||
// Fetch gift wrap events
|
||||
let sub_id = css().gift_wrap_sub_id.clone();
|
||||
let sub_id = app_state().gift_wrap_sub_id.clone();
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
|
||||
if client
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::rc::Rc;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window,
|
||||
SharedString, SharedUri, StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use i18n::t;
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -24,7 +24,7 @@ pub struct RoomListItem {
|
||||
room_id: Option<u64>,
|
||||
public_key: Option<PublicKey>,
|
||||
name: Option<SharedString>,
|
||||
avatar: Option<SharedString>,
|
||||
avatar: Option<SharedUri>,
|
||||
created_at: Option<SharedString>,
|
||||
kind: Option<RoomKind>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -60,7 +60,7 @@ impl RoomListItem {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self {
|
||||
pub fn avatar(mut self, avatar: impl Into<SharedUri>) -> Self {
|
||||
self.avatar = Some(avatar.into());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::debounced_delay::DebouncedDelay;
|
||||
use common::display::{ReadableTimestamp, TextUtils};
|
||||
use common::display::{RenderedTimestamp, TextUtils};
|
||||
use global::constants::{BOOTSTRAP_RELAYS, SEARCH_RELAYS};
|
||||
use global::{css, nostr_client, UnwrappingStatus};
|
||||
use global::{app_state, nostr_client, UnwrappingStatus};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, InteractiveElement, IntoElement, ParentElement, Render, RetainAllImageCache,
|
||||
SharedString, Styled, Subscription, Task, Window,
|
||||
deferred, div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
|
||||
RetainAllImageCache, SharedString, Styled, Subscription, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use i18n::{shared_t, t};
|
||||
@@ -23,7 +23,7 @@ use registry::{Registry, RegistryEvent};
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonRounded, ButtonVariants};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::popup_menu::{PopupMenu, PopupMenuExt};
|
||||
@@ -56,7 +56,7 @@ pub struct Sidebar {
|
||||
focus_handle: FocusHandle,
|
||||
image_cache: Entity<RetainAllImageCache>,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 2]>,
|
||||
subscriptions: SmallVec<[Subscription; 3]>,
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
@@ -77,23 +77,30 @@ impl Sidebar {
|
||||
let registry = Registry::global(cx);
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
®istry,
|
||||
window,
|
||||
move |this, _, event, _window, cx| {
|
||||
subscriptions.push(
|
||||
// Clear the image cache when sidebar is closed
|
||||
cx.on_release_in(window, move |this, window, cx| {
|
||||
this.image_cache.update(cx, |this, cx| {
|
||||
this.clear(window, cx);
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe for registry new events
|
||||
cx.subscribe_in(®istry, window, move |this, _, event, _window, cx| {
|
||||
if let RegistryEvent::NewRequest(kind) = event {
|
||||
this.indicator.update(cx, |this, cx| {
|
||||
*this = Some(kind.to_owned());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
},
|
||||
));
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&find_input,
|
||||
window,
|
||||
|this, _state, event, window, cx| {
|
||||
subscriptions.push(
|
||||
// Subscribe for find input events
|
||||
cx.subscribe_in(&find_input, window, |this, _state, event, window, cx| {
|
||||
match event {
|
||||
InputEvent::PressEnter { .. } => this.search(window, cx),
|
||||
InputEvent::Change(text) => {
|
||||
@@ -112,8 +119,8 @@ impl Sidebar {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
));
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Sidebar".into(),
|
||||
@@ -155,7 +162,7 @@ impl Sidebar {
|
||||
Self::request_metadata(client, public_key).await?;
|
||||
|
||||
// Create a temporary room
|
||||
let room = Room::new(&event).rearrange_by(identity);
|
||||
let room = Room::from(&event).current_user(identity);
|
||||
|
||||
Ok(room)
|
||||
}
|
||||
@@ -530,8 +537,8 @@ impl Sidebar {
|
||||
fn on_manage(&mut self, _ev: &RelayStatus, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let task: Task<Result<Vec<Relay>, Error>> = cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let css = css();
|
||||
let subscription = client.subscription(&css.gift_wrap_sub_id).await;
|
||||
let app_state = app_state();
|
||||
let subscription = client.subscription(&app_state.gift_wrap_sub_id).await;
|
||||
let mut relays: Vec<Relay> = vec![];
|
||||
|
||||
for (url, _filter) in subscription.into_iter() {
|
||||
@@ -669,6 +676,7 @@ impl Focusable for Sidebar {
|
||||
impl Render for Sidebar {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let registry = Registry::read_global(cx);
|
||||
let loading = registry.unwrapping_status.read(cx) != &UnwrappingStatus::Complete;
|
||||
|
||||
// Get rooms from either search results or the chat registry
|
||||
let rooms = if let Some(results) = self.local_result.read(cx).as_ref() {
|
||||
@@ -688,7 +696,7 @@ impl Render for Sidebar {
|
||||
let mut total_rooms = rooms.len();
|
||||
|
||||
// Add 3 dummy rooms to display as skeletons
|
||||
if registry.unwrapping_status.read(cx) != &UnwrappingStatus::Complete {
|
||||
if loading {
|
||||
total_rooms += 3
|
||||
}
|
||||
|
||||
@@ -743,16 +751,16 @@ impl Render for Sidebar {
|
||||
.tooltip(t!("sidebar.all_conversations_tooltip"))
|
||||
.when_some(self.indicator.read(cx).as_ref(), |this, kind| {
|
||||
this.when(kind == &RoomKind::Ongoing, |this| {
|
||||
this.child(
|
||||
this.child(deferred(
|
||||
div().size_1().rounded_full().bg(cx.theme().cursor),
|
||||
)
|
||||
))
|
||||
})
|
||||
})
|
||||
.small()
|
||||
.cta()
|
||||
.bold()
|
||||
.secondary()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.rounded()
|
||||
.selected(self.filter(&RoomKind::Ongoing, cx))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.set_filter(RoomKind::Ongoing, cx);
|
||||
@@ -764,16 +772,16 @@ impl Render for Sidebar {
|
||||
.tooltip(t!("sidebar.requests_tooltip"))
|
||||
.when_some(self.indicator.read(cx).as_ref(), |this, kind| {
|
||||
this.when(kind != &RoomKind::Ongoing, |this| {
|
||||
this.child(
|
||||
this.child(deferred(
|
||||
div().size_1().rounded_full().bg(cx.theme().cursor),
|
||||
)
|
||||
))
|
||||
})
|
||||
})
|
||||
.small()
|
||||
.cta()
|
||||
.bold()
|
||||
.secondary()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.rounded()
|
||||
.selected(!self.filter(&RoomKind::Ongoing, cx))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.set_filter(RoomKind::default(), cx);
|
||||
@@ -791,7 +799,7 @@ impl Render for Sidebar {
|
||||
.icon(IconName::Ellipsis)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.rounded()
|
||||
.popup_menu(move |this, _window, _cx| {
|
||||
this.menu(
|
||||
t!("sidebar.reload_menu"),
|
||||
@@ -805,6 +813,57 @@ impl Render for Sidebar {
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(!loading && total_rooms == 0, |this| {
|
||||
this.map(|this| {
|
||||
if self.filter(&RoomKind::Ongoing, cx) {
|
||||
this.child(deferred(
|
||||
v_flex()
|
||||
.py_2()
|
||||
.gap_1p5()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(shared_t!("sidebar.no_conversations")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.line_height(relative(1.25))
|
||||
.child(shared_t!("sidebar.no_conversations_label")),
|
||||
),
|
||||
))
|
||||
} else {
|
||||
this.child(deferred(
|
||||
v_flex()
|
||||
.py_2()
|
||||
.gap_1p5()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(shared_t!("sidebar.no_requests")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.line_height(relative(1.25))
|
||||
.child(shared_t!("sidebar.no_requests_label")),
|
||||
),
|
||||
))
|
||||
}
|
||||
})
|
||||
})
|
||||
.child(
|
||||
uniform_list(
|
||||
"rooms",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use common::display::ReadableProfile;
|
||||
use common::display::RenderedProfile;
|
||||
use common::nip05::nip05_verify;
|
||||
use global::nostr_client;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
@@ -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 Ok(bech32) = self.profile.public_key().to_bech32();
|
||||
let shared_bech32 = SharedString::new(bech32);
|
||||
let bech32 = self.profile.public_key().to_bech32().unwrap();
|
||||
let shared_bech32 = SharedString::from(bech32);
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
@@ -140,7 +139,7 @@ impl Render for UserProfile {
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(Avatar::new(self.profile.avatar_url(proxy)).size(rems(4.)))
|
||||
.child(Avatar::new(self.profile.avatar(proxy)).size(rems(4.)))
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
@@ -194,7 +193,7 @@ impl Render for UserProfile {
|
||||
div()
|
||||
.block()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Public Key:"),
|
||||
.child(SharedString::from("Public Key:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -245,7 +244,8 @@ impl Render for UserProfile {
|
||||
self.profile
|
||||
.metadata()
|
||||
.about
|
||||
.unwrap_or(t!("profile.no_bio").to_string()),
|
||||
.map(SharedString::from)
|
||||
.unwrap_or(shared_t!("profile.no_bio")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use gpui::{
|
||||
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::Button;
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::popup_menu::PopupMenu;
|
||||
use ui::StyledExt;
|
||||
use ui::{v_flex, StyledExt};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
|
||||
Welcome::new(window, cx)
|
||||
@@ -14,8 +15,7 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
|
||||
|
||||
pub struct Welcome {
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
version: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
@@ -25,10 +25,11 @@ impl Welcome {
|
||||
}
|
||||
|
||||
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let version = SharedString::from(format!("Version: {}", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
Self {
|
||||
version,
|
||||
name: "Welcome".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
@@ -39,16 +40,15 @@ impl Panel for Welcome {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
"👋".into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_4()
|
||||
.text_color(cx.theme().element_background),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
@@ -76,11 +76,10 @@ impl Render for Welcome {
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.justify_center()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
@@ -88,11 +87,26 @@ impl Render for Welcome {
|
||||
.text_color(cx.theme().elevated_surface_background),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.child("coop on nostr")
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
.text_sm(),
|
||||
v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("coop on nostr")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("version")
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_xs()
|
||||
.child(self.version.clone())
|
||||
.on_click(|_, _window, cx| {
|
||||
cx.open_url("https://github.com/lumehq/coop/releases");
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
|
||||
pub const RELAY_RETRY: u64 = 2;
|
||||
|
||||
/// Default retry count for sending messages
|
||||
pub const SEND_RETRY: u64 = 5;
|
||||
pub const SEND_RETRY: u64 = 10;
|
||||
|
||||
/// Default timeout (in seconds) for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
|
||||
|
||||
+95
-46
@@ -13,16 +13,18 @@ use crate::paths::support_dir;
|
||||
pub mod constants;
|
||||
pub mod paths;
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct AuthRequest {
|
||||
pub challenge: String,
|
||||
pub url: RelayUrl,
|
||||
pub challenge: String,
|
||||
pub sending: bool,
|
||||
}
|
||||
|
||||
impl AuthRequest {
|
||||
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
|
||||
Self {
|
||||
challenge: challenge.into(),
|
||||
sending: false,
|
||||
url,
|
||||
}
|
||||
}
|
||||
@@ -55,7 +57,7 @@ pub enum UnwrappingStatus {
|
||||
|
||||
/// Signals sent through the global event channel to notify UI
|
||||
#[derive(Debug)]
|
||||
pub enum Signal {
|
||||
pub enum SignalKind {
|
||||
/// A signal to notify UI that the client's signer has been set
|
||||
SignerSet(PublicKey),
|
||||
|
||||
@@ -68,26 +70,55 @@ pub enum Signal {
|
||||
/// A signal to notify UI that the browser proxy service is down
|
||||
ProxyDown,
|
||||
|
||||
/// A signal to notify UI that a new metadata event has been received
|
||||
Metadata(Event),
|
||||
/// A signal to notify UI that a new profile has been received
|
||||
NewProfile(Profile),
|
||||
|
||||
/// A signal to notify UI that a new gift wrap event has been received
|
||||
Message((EventId, Event)),
|
||||
NewMessage((EventId, Event)),
|
||||
|
||||
/// A signal to notify UI that gift wrap process status has changed
|
||||
GiftWrapProcess(UnwrappingStatus),
|
||||
/// A signal to notify UI that no DM relays for current user was found
|
||||
RelaysNotFound,
|
||||
|
||||
/// A signal to notify UI that no DM relay for current user was found
|
||||
DmRelayNotFound,
|
||||
/// A signal to notify UI that gift wrap status has changed
|
||||
GiftWrapStatus(UnwrappingStatus),
|
||||
|
||||
/// A signal to notify UI that there are errors or notices occurred
|
||||
Notice(Notice),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Signal {
|
||||
rx: Receiver<SignalKind>,
|
||||
tx: Sender<SignalKind>,
|
||||
}
|
||||
|
||||
impl Default for Signal {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Signal {
|
||||
pub fn new() -> Self {
|
||||
let (tx, rx) = flume::bounded::<SignalKind>(2048);
|
||||
Self { rx, tx }
|
||||
}
|
||||
|
||||
pub fn receiver(&self) -> &Receiver<SignalKind> {
|
||||
&self.rx
|
||||
}
|
||||
|
||||
pub async fn send(&self, kind: SignalKind) {
|
||||
if let Err(e) = self.tx.send_async(kind).await {
|
||||
log::error!("Failed to send signal: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Ingester {
|
||||
rx: Receiver<Signal>,
|
||||
tx: Sender<Signal>,
|
||||
rx: Receiver<PublicKey>,
|
||||
tx: Sender<PublicKey>,
|
||||
}
|
||||
|
||||
impl Default for Ingester {
|
||||
@@ -98,48 +129,74 @@ impl Default for Ingester {
|
||||
|
||||
impl Ingester {
|
||||
pub fn new() -> Self {
|
||||
let (tx, rx) = flume::bounded::<Signal>(2048);
|
||||
let (tx, rx) = flume::bounded::<PublicKey>(1024);
|
||||
Self { rx, tx }
|
||||
}
|
||||
|
||||
pub fn signals(&self) -> &Receiver<Signal> {
|
||||
pub fn receiver(&self) -> &Receiver<PublicKey> {
|
||||
&self.rx
|
||||
}
|
||||
|
||||
pub async fn send(&self, signal: Signal) {
|
||||
if let Err(e) = self.tx.send_async(signal).await {
|
||||
log::error!("Failed to send signal: {e}");
|
||||
pub async fn send(&self, public_key: PublicKey) {
|
||||
if let Err(e) = self.tx.send_async(public_key).await {
|
||||
log::error!("Failed to send public key: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple storage to store all runtime states that using across the application.
|
||||
/// A simple storage to store all states that using across the application.
|
||||
#[derive(Debug)]
|
||||
pub struct CoopSimpleStorage {
|
||||
pub struct AppState {
|
||||
pub init_at: Timestamp,
|
||||
|
||||
pub last_used_at: Option<Timestamp>,
|
||||
|
||||
pub is_first_run: AtomicBool,
|
||||
|
||||
pub gift_wrap_sub_id: SubscriptionId,
|
||||
|
||||
pub gift_wrap_processing: AtomicBool,
|
||||
|
||||
pub auto_close_opts: Option<SubscribeAutoCloseOptions>,
|
||||
|
||||
pub seen_on_relays: RwLock<HashMap<EventId, HashSet<RelayUrl>>>,
|
||||
|
||||
pub sent_ids: RwLock<HashSet<EventId>>,
|
||||
|
||||
pub resent_ids: RwLock<Vec<Output<EventId>>>,
|
||||
|
||||
pub resend_queue: RwLock<HashMap<EventId, RelayUrl>>,
|
||||
|
||||
pub signal: Signal,
|
||||
|
||||
pub ingester: Ingester,
|
||||
}
|
||||
|
||||
impl Default for CoopSimpleStorage {
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CoopSimpleStorage {
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
let init_at = Timestamp::now();
|
||||
let first_run = first_run();
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
let signal = Signal::default();
|
||||
let ingester = Ingester::default();
|
||||
|
||||
Self {
|
||||
init_at: Timestamp::now(),
|
||||
init_at,
|
||||
signal,
|
||||
ingester,
|
||||
last_used_at: None,
|
||||
is_first_run: AtomicBool::new(first_run),
|
||||
gift_wrap_sub_id: SubscriptionId::new("inbox"),
|
||||
gift_wrap_processing: AtomicBool::new(false),
|
||||
auto_close_opts: Some(
|
||||
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE),
|
||||
),
|
||||
auto_close_opts: Some(opts),
|
||||
seen_on_relays: RwLock::new(HashMap::new()),
|
||||
sent_ids: RwLock::new(HashSet::new()),
|
||||
resent_ids: RwLock::new(Vec::new()),
|
||||
resend_queue: RwLock::new(HashMap::new()),
|
||||
@@ -148,9 +205,7 @@ impl CoopSimpleStorage {
|
||||
}
|
||||
|
||||
static NOSTR_CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
static INGESTER: OnceLock<Ingester> = OnceLock::new();
|
||||
static COOP_SIMPLE_STORAGE: OnceLock<CoopSimpleStorage> = OnceLock::new();
|
||||
static FIRST_RUN: OnceLock<bool> = OnceLock::new();
|
||||
static APP_STATE: OnceLock<AppState> = OnceLock::new();
|
||||
|
||||
pub fn nostr_client() -> &'static Client {
|
||||
NOSTR_CLIENT.get_or_init(|| {
|
||||
@@ -168,32 +223,26 @@ pub fn nostr_client() -> &'static Client {
|
||||
.automatic_authentication(false)
|
||||
.verify_subscriptions(false)
|
||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||
timeout: Duration::from_secs(30),
|
||||
timeout: Duration::from_secs(300),
|
||||
});
|
||||
|
||||
ClientBuilder::default().database(lmdb).opts(opts).build()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ingester() -> &'static Ingester {
|
||||
INGESTER.get_or_init(Ingester::new)
|
||||
pub fn app_state() -> &'static AppState {
|
||||
APP_STATE.get_or_init(AppState::new)
|
||||
}
|
||||
|
||||
pub fn css() -> &'static CoopSimpleStorage {
|
||||
COOP_SIMPLE_STORAGE.get_or_init(CoopSimpleStorage::new)
|
||||
}
|
||||
fn first_run() -> bool {
|
||||
let flag = support_dir().join(format!(".{}-first_run", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
pub fn first_run() -> &'static bool {
|
||||
FIRST_RUN.get_or_init(|| {
|
||||
let flag = support_dir().join(format!(".{}-first_run", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
if !flag.exists() {
|
||||
if std::fs::write(&flag, "").is_err() {
|
||||
return false;
|
||||
}
|
||||
true // First run
|
||||
} else {
|
||||
false // Not first run
|
||||
if !flag.exists() {
|
||||
if std::fs::write(&flag, "").is_err() {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
true // First run
|
||||
} else {
|
||||
false // Not first run
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@ macro_rules! init {
|
||||
#[macro_export]
|
||||
macro_rules! shared_t {
|
||||
($key:expr) => {
|
||||
SharedString::new(t!($key))
|
||||
SharedString::from(t!($key))
|
||||
};
|
||||
($key:expr, $($param:ident = $value:expr),+) => {
|
||||
SharedString::new(t!($key, $($param = $value),+))
|
||||
SharedString::from(t!($key, $($param = $value),+))
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+14
-18
@@ -155,21 +155,19 @@ impl Registry {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
// Invalid metadata, no need to process further.
|
||||
return;
|
||||
};
|
||||
pub fn insert_or_update_person(&mut self, profile: Profile, cx: &mut App) {
|
||||
let public_key = profile.public_key();
|
||||
|
||||
if let Some(person) = self.persons.get(&public_key) {
|
||||
person.update(cx, |this, cx| {
|
||||
*this = Profile::new(public_key, metadata);
|
||||
cx.notify();
|
||||
});
|
||||
} else {
|
||||
self.persons
|
||||
.insert(public_key, cx.new(|_| Profile::new(public_key, metadata)));
|
||||
match self.persons.get(&public_key) {
|
||||
Some(person) => {
|
||||
person.update(cx, |this, cx| {
|
||||
*this = profile;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
None => {
|
||||
self.persons.insert(public_key, cx.new(|_| profile));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +324,7 @@ impl Registry {
|
||||
let is_ongoing = client.database().count(filter).await.unwrap_or(1) >= 1;
|
||||
|
||||
// Create a new room
|
||||
let room = Room::new(&event).rearrange_by(public_key);
|
||||
let room = Room::from(&event).current_user(public_key);
|
||||
|
||||
if is_ongoing || bypassed {
|
||||
rooms.insert(room.kind(RoomKind::Ongoing));
|
||||
@@ -460,9 +458,7 @@ impl Registry {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let room = Room::new(&event)
|
||||
.kind(RoomKind::default())
|
||||
.rearrange_by(identity);
|
||||
let room = Room::from(&event).current_user(identity);
|
||||
|
||||
// Push the new room to the front of the list
|
||||
self.add_room(cx.new(|_| room), cx);
|
||||
|
||||
@@ -5,6 +5,7 @@ use nostr_sdk::prelude::*;
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
pub enum Message {
|
||||
User(RenderedMessage),
|
||||
Warning(String, Timestamp),
|
||||
System(Timestamp),
|
||||
}
|
||||
|
||||
@@ -13,6 +14,10 @@ impl Message {
|
||||
Self::User(user.into())
|
||||
}
|
||||
|
||||
pub fn warning(content: String) -> Self {
|
||||
Self::Warning(content, Timestamp::now())
|
||||
}
|
||||
|
||||
pub fn system() -> Self {
|
||||
Self::System(Timestamp::default())
|
||||
}
|
||||
@@ -25,6 +30,11 @@ impl Ord for Message {
|
||||
(Message::System(a), Message::System(b)) => a.cmp(b),
|
||||
(Message::User(a), Message::System(b)) => a.created_at.cmp(b),
|
||||
(Message::System(a), Message::User(b)) => a.cmp(&b.created_at),
|
||||
(Message::Warning(_, a), Message::Warning(_, b)) => a.cmp(b),
|
||||
(Message::Warning(_, a), Message::User(b)) => a.cmp(&b.created_at),
|
||||
(Message::User(a), Message::Warning(_, b)) => a.created_at.cmp(b),
|
||||
(Message::Warning(_, a), Message::System(b)) => a.cmp(b),
|
||||
(Message::System(a), Message::Warning(_, b)) => a.cmp(b),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+257
-181
@@ -1,13 +1,14 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use common::display::ReadableProfile;
|
||||
use common::display::RenderedProfile;
|
||||
use common::event::EventUtils;
|
||||
use global::constants::SEND_RETRY;
|
||||
use global::{css, nostr_client};
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||
use global::{app_state, nostr_client};
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, SharedUri, Task};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
@@ -16,45 +17,51 @@ use crate::Registry;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SendReport {
|
||||
pub receiver: PublicKey,
|
||||
pub output: Option<Output<EventId>>,
|
||||
pub local_error: Option<SharedString>,
|
||||
pub nip17_relays_not_found: bool,
|
||||
pub tags: Option<Vec<Tag>>,
|
||||
pub status: Option<Output<EventId>>,
|
||||
pub error: Option<SharedString>,
|
||||
pub relays_not_found: bool,
|
||||
}
|
||||
|
||||
impl SendReport {
|
||||
pub fn output(receiver: PublicKey, output: Output<EventId>) -> Self {
|
||||
pub fn new(receiver: PublicKey) -> Self {
|
||||
Self {
|
||||
receiver,
|
||||
output: Some(output),
|
||||
local_error: None,
|
||||
nip17_relays_not_found: false,
|
||||
status: None,
|
||||
error: None,
|
||||
tags: None,
|
||||
relays_not_found: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(receiver: PublicKey, error: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
receiver,
|
||||
output: None,
|
||||
local_error: Some(error.into()),
|
||||
nip17_relays_not_found: false,
|
||||
}
|
||||
pub fn not_found(mut self) -> Self {
|
||||
self.relays_not_found = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn nip17_relays_not_found(receiver: PublicKey) -> Self {
|
||||
Self {
|
||||
receiver,
|
||||
output: None,
|
||||
local_error: None,
|
||||
nip17_relays_not_found: true,
|
||||
}
|
||||
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
|
||||
self.error = Some(error.into());
|
||||
self.relays_not_found = false;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn status(mut self, output: Output<EventId>) -> Self {
|
||||
self.status = Some(output);
|
||||
self.relays_not_found = false;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tags(mut self, tags: &Vec<Tag>) -> Self {
|
||||
self.tags = Some(tags.to_owned());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_relay_error(&self) -> bool {
|
||||
self.local_error.is_some() || self.nip17_relays_not_found
|
||||
self.error.is_some() || self.relays_not_found
|
||||
}
|
||||
|
||||
pub fn is_sent_success(&self) -> bool {
|
||||
if let Some(output) = self.output.as_ref() {
|
||||
if let Some(output) = self.status.as_ref() {
|
||||
!output.success.is_empty()
|
||||
} else {
|
||||
false
|
||||
@@ -117,13 +124,13 @@ impl Eq for Room {}
|
||||
|
||||
impl EventEmitter<RoomSignal> for Room {}
|
||||
|
||||
impl Room {
|
||||
pub fn new(event: &Event) -> Self {
|
||||
let id = event.uniq_id();
|
||||
let created_at = event.created_at;
|
||||
impl From<&Event> for Room {
|
||||
fn from(val: &Event) -> Self {
|
||||
let id = val.uniq_id();
|
||||
let created_at = val.created_at;
|
||||
|
||||
// Get the members from the event's tags and event's pubkey
|
||||
let members = event
|
||||
let members = val
|
||||
.all_pubkeys()
|
||||
.into_iter()
|
||||
.unique()
|
||||
@@ -131,20 +138,20 @@ impl Room {
|
||||
.collect_vec();
|
||||
|
||||
// Get the subject from the event's tags
|
||||
let subject = if let Some(tag) = event.tags.find(TagKind::Subject) {
|
||||
let subject = if let Some(tag) = val.tags.find(TagKind::Subject) {
|
||||
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")) {
|
||||
let picture = if let Some(tag) = val.tags.find(TagKind::custom("picture")) {
|
||||
tag.content().map(|s| s.to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
Room {
|
||||
id,
|
||||
created_at,
|
||||
subject,
|
||||
@@ -153,47 +160,82 @@ impl Room {
|
||||
kind: RoomKind::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the kind of the room and returns the modified room
|
||||
///
|
||||
/// This is a builder-style method that allows chaining room modifications.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `kind` - The RoomKind to set for this room
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The modified Room instance with the new kind
|
||||
pub fn kind(mut self, kind: RoomKind) -> Self {
|
||||
self.kind = kind;
|
||||
self
|
||||
impl From<&UnsignedEvent> for Room {
|
||||
fn from(val: &UnsignedEvent) -> Self {
|
||||
let id = val.uniq_id();
|
||||
let created_at = val.created_at;
|
||||
|
||||
// Get the members from the event's tags and event's pubkey
|
||||
let members = val
|
||||
.all_pubkeys()
|
||||
.into_iter()
|
||||
.unique()
|
||||
.sorted()
|
||||
.collect_vec();
|
||||
|
||||
// Get the subject from the event's tags
|
||||
let subject = if let Some(tag) = val.tags.find(TagKind::Subject) {
|
||||
tag.content().map(|s| s.to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get the picture from the event's tags
|
||||
let picture = if let Some(tag) = val.tags.find(TagKind::custom("picture")) {
|
||||
tag.content().map(|s| s.to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Room {
|
||||
id,
|
||||
created_at,
|
||||
subject,
|
||||
picture,
|
||||
members,
|
||||
kind: RoomKind::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Room {
|
||||
/// Constructs a new room instance with a given receiver.
|
||||
pub fn new(receiver: PublicKey, tags: Tags, cx: &App) -> Self {
|
||||
let identity = Registry::read_global(cx).identity(cx);
|
||||
|
||||
let mut event = EventBuilder::private_msg_rumor(receiver, "")
|
||||
.tags(tags)
|
||||
.build(identity.public_key());
|
||||
|
||||
// Ensure event ID is generated
|
||||
event.ensure_id();
|
||||
|
||||
Room::from(&event).current_user(identity.public_key())
|
||||
}
|
||||
|
||||
/// Sets the rearrange_by field of the room and returns the modified room
|
||||
///
|
||||
/// This is a builder-style method that allows chaining room modifications.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `rearrange_by` - The PublicKey to set for rearranging the member list
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The modified Room instance with the new member list after rearrangement
|
||||
pub fn rearrange_by(mut self, rearrange_by: PublicKey) -> Self {
|
||||
/// Constructs a new room instance from an nostr event.
|
||||
pub fn from(event: impl Into<Room>) -> Self {
|
||||
event.into()
|
||||
}
|
||||
|
||||
/// Call this function to ensure the current user is always at the bottom of the members list
|
||||
pub fn current_user(mut self, public_key: PublicKey) -> Self {
|
||||
let (not_match, matches): (Vec<PublicKey>, Vec<PublicKey>) =
|
||||
self.members.iter().partition(|&key| key != &rearrange_by);
|
||||
self.members.iter().partition(|&key| key != &public_key);
|
||||
self.members = not_match;
|
||||
self.members.extend(matches);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the kind of the room and returns the modified room
|
||||
pub fn kind(mut self, kind: RoomKind) -> Self {
|
||||
self.kind = kind;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the room kind to ongoing
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The context to notify about the update
|
||||
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
|
||||
if self.kind != RoomKind::Ongoing {
|
||||
self.kind = RoomKind::Ongoing;
|
||||
@@ -202,89 +244,45 @@ impl Room {
|
||||
}
|
||||
|
||||
/// Checks if the room is a group chat
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// true if the room has more than 2 members, false otherwise
|
||||
pub fn is_group(&self) -> bool {
|
||||
self.members.len() > 2
|
||||
}
|
||||
|
||||
/// Updates the creation timestamp of the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `created_at` - The new Timestamp to set
|
||||
/// * `cx` - The context to notify about the update
|
||||
pub fn created_at(&mut self, created_at: impl Into<Timestamp>, cx: &mut Context<Self>) {
|
||||
self.created_at = created_at.into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Updates the subject of the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `subject` - The new subject to set
|
||||
/// * `cx` - The context to notify about the update
|
||||
pub fn subject(&mut self, subject: String, cx: &mut Context<Self>) {
|
||||
self.subject = Some(subject);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Updates the picture of the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `picture` - The new subject to set
|
||||
/// * `cx` - The context to notify about the update
|
||||
pub fn picture(&mut self, picture: String, cx: &mut Context<Self>) {
|
||||
self.picture = Some(picture);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Gets the display name for the room
|
||||
///
|
||||
/// If the room has a subject set, that will be used as the display name.
|
||||
/// Otherwise, it will generate a name based on the room members.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The application context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A string containing the display name
|
||||
pub fn display_name(&self, cx: &App) -> String {
|
||||
pub fn display_name(&self, cx: &App) -> SharedString {
|
||||
if let Some(subject) = self.subject.clone() {
|
||||
subject
|
||||
SharedString::from(subject)
|
||||
} else {
|
||||
self.merge_name(cx)
|
||||
self.merged_name(cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the display image for the room
|
||||
///
|
||||
/// The image is determined by:
|
||||
/// - The room's picture if set
|
||||
/// - The first member's avatar for 1:1 chats
|
||||
/// - A default group image for group chats
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `proxy` - Whether to use the proxy for the avatar URL
|
||||
/// * `cx` - The application context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A string containing the image path or URL
|
||||
pub fn display_image(&self, proxy: bool, cx: &App) -> String {
|
||||
pub fn display_image(&self, proxy: bool, cx: &App) -> SharedUri {
|
||||
if let Some(picture) = self.picture.as_ref() {
|
||||
picture.clone()
|
||||
SharedUri::from(picture)
|
||||
} else if !self.is_group() {
|
||||
self.first_member(cx).avatar_url(proxy)
|
||||
self.first_member(cx).avatar(proxy)
|
||||
} else {
|
||||
"brand/group.png".into()
|
||||
SharedUri::from("brand/group.png")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +295,7 @@ impl Room {
|
||||
}
|
||||
|
||||
/// Merge the names of the first two members of the room.
|
||||
pub(crate) fn merge_name(&self, cx: &App) -> String {
|
||||
pub(crate) fn merged_name(&self, cx: &App) -> SharedString {
|
||||
let registry = Registry::read_global(cx);
|
||||
|
||||
if self.is_group() {
|
||||
@@ -310,7 +308,7 @@ impl Room {
|
||||
let mut name = profiles
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|p| p.display_name())
|
||||
.map(|p| p.name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
@@ -318,54 +316,75 @@ impl Room {
|
||||
name = format!("{}, +{}", name, profiles.len() - 2);
|
||||
}
|
||||
|
||||
name
|
||||
SharedString::from(name)
|
||||
} else {
|
||||
self.first_member(cx).display_name()
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects to all members' messaging relays
|
||||
pub fn connect_relays(
|
||||
&self,
|
||||
cx: &App,
|
||||
) -> Task<Result<HashMap<PublicKey, Vec<RelayUrl>>, Error>> {
|
||||
let members = self.members.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let timeout = Duration::from_secs(3);
|
||||
let mut processed = HashSet::new();
|
||||
let mut relays: HashMap<PublicKey, Vec<RelayUrl>> = HashMap::new();
|
||||
|
||||
if let Some((_, members)) = members.split_last() {
|
||||
for member in members.iter() {
|
||||
relays.insert(member.to_owned(), vec![]);
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(member.to_owned())
|
||||
.limit(1);
|
||||
|
||||
if let Ok(mut stream) = client.stream_events(filter, timeout).await {
|
||||
if let Some(event) = stream.next().await {
|
||||
if processed.insert(event.id) {
|
||||
let urls = nip17::extract_owned_relay_list(event).collect_vec();
|
||||
relays.entry(member.to_owned()).or_default().extend(urls);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(relays)
|
||||
})
|
||||
}
|
||||
|
||||
/// Loads all messages for this room from the database
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A Task that resolves to Result<Vec<Event>, Error> containing all messages for this room
|
||||
pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<Event>, Error>> {
|
||||
let members = self.members.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let public_key = members[members.len() - 1];
|
||||
|
||||
let filter = Filter::new()
|
||||
let sent = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.authors(members.clone())
|
||||
.author(public_key)
|
||||
.pubkeys(members.clone());
|
||||
|
||||
let events: Vec<Event> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|ev| ev.compare_pubkeys(&members))
|
||||
.collect();
|
||||
let recv = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.authors(members)
|
||||
.pubkey(public_key);
|
||||
|
||||
let sent_events = client.database().query(sent).await?;
|
||||
let recv_events = client.database().query(recv).await?;
|
||||
let events: Vec<Event> = sent_events.merge(recv_events).into_iter().collect();
|
||||
|
||||
Ok(events)
|
||||
})
|
||||
}
|
||||
|
||||
/// Emits a new message signal to the current room
|
||||
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.
|
||||
pub fn emit_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
cx.emit(RoomSignal::Refresh);
|
||||
}
|
||||
|
||||
/// Creates a temporary message for optimistic updates
|
||||
///
|
||||
/// The event must not been published to relays.
|
||||
@@ -392,23 +411,14 @@ impl Room {
|
||||
}
|
||||
|
||||
let mut event = builder.tags(tags).build(receiver);
|
||||
|
||||
// Ensure event ID is set
|
||||
event.ensure_id();
|
||||
|
||||
event
|
||||
}
|
||||
|
||||
/// Sends a message to all members in the background task
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `content` - The content of the message to send
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A Task that resolves to Result<Vec<String>, Error> where the
|
||||
/// strings contain error messages for any failed sends
|
||||
/// Create a task to sends a message to all members in the background
|
||||
pub fn send_in_background(
|
||||
&self,
|
||||
content: &str,
|
||||
@@ -422,20 +432,21 @@ impl Room {
|
||||
let mut public_keys = self.members.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let app_state = app_state();
|
||||
let client = nostr_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let mut tags = public_keys
|
||||
let mut tags: Vec<Tag> = public_keys
|
||||
.iter()
|
||||
.filter_map(|pubkey| {
|
||||
if pubkey != &public_key {
|
||||
Some(Tag::public_key(*pubkey))
|
||||
.filter_map(|&this| {
|
||||
if this != public_key {
|
||||
Some(Tag::public_key(this))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect_vec();
|
||||
.collect();
|
||||
|
||||
// Add event reference if it's present (replying to another event)
|
||||
if replies.len() == 1 {
|
||||
@@ -468,44 +479,45 @@ impl Room {
|
||||
// Stored all send errors
|
||||
let mut reports = vec![];
|
||||
|
||||
for receiver in public_keys.into_iter() {
|
||||
for pubkey in public_keys.into_iter() {
|
||||
match client
|
||||
.send_private_msg(receiver, &content, tags.clone())
|
||||
.send_private_msg(pubkey, &content, tags.clone())
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
if output
|
||||
.failed
|
||||
.iter()
|
||||
.any(|(_, msg)| msg.starts_with("auth-required:"))
|
||||
{
|
||||
let id = output.id();
|
||||
let id = output.id().to_owned();
|
||||
let auth_required = output.failed.iter().any(|m| m.1.starts_with("auth-"));
|
||||
let report = SendReport::new(pubkey).status(output).tags(&tags);
|
||||
|
||||
if auth_required {
|
||||
// Wait for authenticated and resent event successfully
|
||||
for attempt in 0..=SEND_RETRY {
|
||||
let ids = app_state.resent_ids.read().await;
|
||||
|
||||
// Check if event was successfully resent
|
||||
if let Some(output) =
|
||||
css().resent_ids.read().await.iter().find(|o| o.id() == id)
|
||||
{
|
||||
reports.push(SendReport::output(receiver, output.to_owned()));
|
||||
if let Some(output) = ids.iter().find(|e| e.id() == &id).cloned() {
|
||||
let output = SendReport::new(pubkey).status(output).tags(&tags);
|
||||
reports.push(output);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if retry limit exceeded
|
||||
if attempt == SEND_RETRY {
|
||||
reports.push(report);
|
||||
break;
|
||||
}
|
||||
|
||||
smol::Timer::after(Duration::from_secs(1)).await;
|
||||
smol::Timer::after(Duration::from_millis(1200)).await;
|
||||
}
|
||||
} else {
|
||||
reports.push(SendReport::output(receiver, output));
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let nostr_sdk::client::Error::PrivateMsgRelaysNotFound = e {
|
||||
reports.push(SendReport::nip17_relays_not_found(receiver));
|
||||
reports.push(SendReport::new(pubkey).not_found().tags(&tags));
|
||||
} else {
|
||||
reports.push(SendReport::error(receiver, e.to_string()));
|
||||
reports.push(SendReport::new(pubkey).error(e.to_string()).tags(&tags));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,13 +530,14 @@ impl Room {
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
reports.push(SendReport::output(public_key, output));
|
||||
reports.push(SendReport::new(public_key).status(output).tags(&tags));
|
||||
}
|
||||
Err(e) => {
|
||||
if let nostr_sdk::client::Error::PrivateMsgRelaysNotFound = e {
|
||||
reports.push(SendReport::nip17_relays_not_found(public_key));
|
||||
reports.push(SendReport::new(public_key).not_found());
|
||||
} else {
|
||||
reports.push(SendReport::error(public_key, e.to_string()));
|
||||
reports
|
||||
.push(SendReport::new(public_key).error(e.to_string()).tags(&tags));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -533,4 +546,67 @@ impl Room {
|
||||
Ok(reports)
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a task to resend a failed message
|
||||
pub fn resend(
|
||||
&self,
|
||||
reports: Vec<SendReport>,
|
||||
message: String,
|
||||
backup: bool,
|
||||
cx: &App,
|
||||
) -> Task<Result<Vec<SendReport>, Error>> {
|
||||
cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let mut resend_reports = vec![];
|
||||
let mut resend_tag = vec![];
|
||||
|
||||
for report in reports.into_iter() {
|
||||
if let Some(output) = report.status {
|
||||
let id = output.id();
|
||||
let urls: Vec<&RelayUrl> = output.failed.keys().collect();
|
||||
|
||||
if let Some(event) = client.database().event_by_id(id).await? {
|
||||
for url in urls.into_iter() {
|
||||
let relay = client.pool().relay(url).await?;
|
||||
let id = relay.send_event(&event).await?;
|
||||
let resent: Output<EventId> = Output {
|
||||
val: id,
|
||||
success: HashSet::from([url.to_owned()]),
|
||||
failed: HashMap::new(),
|
||||
};
|
||||
|
||||
resend_reports.push(SendReport::new(report.receiver).status(resent));
|
||||
}
|
||||
|
||||
if let Some(tags) = report.tags {
|
||||
resend_tag.extend(tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only send a backup message to current user if sent successfully to others
|
||||
if backup && !resend_reports.is_empty() {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let output = client
|
||||
.send_private_msg(public_key, message, resend_tag)
|
||||
.await?;
|
||||
|
||||
resend_reports.push(SendReport::new(public_key).status(output));
|
||||
}
|
||||
|
||||
Ok(resend_reports)
|
||||
})
|
||||
}
|
||||
|
||||
/// Emits a new message signal to the current room
|
||||
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.
|
||||
pub fn emit_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
cx.emit(RoomSignal::Refresh);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
///
|
||||
/// Magic number: There is one extra pixel of padding on the left side due to
|
||||
/// the 1px border around the window on macOS apps.
|
||||
pub const TRAFFIC_LIGHT_PADDING: f32 = 71.;
|
||||
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
|
||||
|
||||
+7
-12
@@ -10,11 +10,6 @@ use crate::indicator::Indicator;
|
||||
use crate::tooltip::Tooltip;
|
||||
use crate::{h_flex, Disableable, Icon, Selectable, Sizable, Size, StyledExt};
|
||||
|
||||
pub enum ButtonRounded {
|
||||
Normal,
|
||||
Full,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ButtonCustomVariant {
|
||||
color: Hsla,
|
||||
@@ -130,7 +125,7 @@ pub struct Button {
|
||||
children: Vec<AnyElement>,
|
||||
|
||||
variant: ButtonVariant,
|
||||
rounded: ButtonRounded,
|
||||
rounded: bool,
|
||||
size: Size,
|
||||
|
||||
disabled: bool,
|
||||
@@ -163,7 +158,7 @@ impl Button {
|
||||
disabled: false,
|
||||
selected: false,
|
||||
variant: ButtonVariant::default(),
|
||||
rounded: ButtonRounded::Normal,
|
||||
rounded: false,
|
||||
size: Size::Medium,
|
||||
tooltip: None,
|
||||
on_click: None,
|
||||
@@ -177,9 +172,9 @@ impl Button {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the border radius of the Button.
|
||||
pub fn rounded(mut self, rounded: impl Into<ButtonRounded>) -> Self {
|
||||
self.rounded = rounded.into();
|
||||
/// Make the button rounded.
|
||||
pub fn rounded(mut self) -> Self {
|
||||
self.rounded = true;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -315,8 +310,8 @@ impl RenderOnce for Button {
|
||||
.cursor_default()
|
||||
.overflow_hidden()
|
||||
.map(|this| match self.rounded {
|
||||
ButtonRounded::Normal => this.rounded(cx.theme().radius),
|
||||
ButtonRounded::Full => this.rounded_full(),
|
||||
false => this.rounded(cx.theme().radius),
|
||||
true => this.rounded_full(),
|
||||
})
|
||||
.map(|this| {
|
||||
if self.label.is_none() && self.children.is_empty() {
|
||||
|
||||
@@ -412,16 +412,15 @@ impl TabPanel {
|
||||
let is_zoomed = self.is_zoomed && state.zoomable;
|
||||
let view = cx.entity().clone();
|
||||
let build_popup_menu = move |this, cx: &App| view.read(cx).popup_menu(this, cx);
|
||||
let toolbar = self.toolbar_buttons(window, cx);
|
||||
let has_toolbar = !toolbar.is_empty();
|
||||
|
||||
h_flex()
|
||||
.p_0p5()
|
||||
.gap_1()
|
||||
.occlude()
|
||||
.items_center()
|
||||
.children(
|
||||
self.toolbar_buttons(window, cx)
|
||||
.into_iter()
|
||||
.map(|btn| btn.small().ghost()),
|
||||
)
|
||||
.rounded_full()
|
||||
.children(toolbar.into_iter().map(|btn| btn.small().ghost().rounded()))
|
||||
.when(self.is_zoomed, |this| {
|
||||
this.child(
|
||||
Button::new("zoom")
|
||||
@@ -434,11 +433,16 @@ impl TabPanel {
|
||||
})),
|
||||
)
|
||||
})
|
||||
.when(has_toolbar, |this| {
|
||||
this.bg(cx.theme().surface_background)
|
||||
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
||||
})
|
||||
.child(
|
||||
Button::new("menu")
|
||||
.icon(IconName::Ellipsis)
|
||||
.small()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.popup_menu({
|
||||
let zoomable = state.zoomable;
|
||||
let closable = state.closable;
|
||||
@@ -647,7 +651,7 @@ impl TabPanel {
|
||||
.child(
|
||||
div()
|
||||
.size_full()
|
||||
.rounded_lg()
|
||||
.rounded_xl()
|
||||
.shadow_sm()
|
||||
.when(cx.theme().mode.is_dark(), |this| this.shadow_lg())
|
||||
.bg(cx.theme().panel_background)
|
||||
@@ -667,7 +671,7 @@ impl TabPanel {
|
||||
.p_1()
|
||||
.child(
|
||||
div()
|
||||
.rounded_lg()
|
||||
.rounded_xl()
|
||||
.border_1()
|
||||
.border_color(cx.theme().element_disabled)
|
||||
.bg(cx.theme().drop_target_background)
|
||||
|
||||
@@ -52,6 +52,7 @@ pub enum IconName {
|
||||
Signal,
|
||||
Search,
|
||||
Settings,
|
||||
Server,
|
||||
SortAscending,
|
||||
SortDescending,
|
||||
Sun,
|
||||
@@ -112,6 +113,7 @@ impl IconName {
|
||||
Self::Signal => "icons/signal.svg",
|
||||
Self::Search => "icons/search.svg",
|
||||
Self::Settings => "icons/settings.svg",
|
||||
Self::Server => "icons/server.svg",
|
||||
Self::SortAscending => "icons/sort-ascending.svg",
|
||||
Self::SortDescending => "icons/sort-descending.svg",
|
||||
Self::Sun => "icons/sun.svg",
|
||||
|
||||
@@ -781,6 +781,11 @@ impl InputState {
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the loading state of the input field.
|
||||
pub fn loading(&self, _cx: &App) -> bool {
|
||||
self.loading
|
||||
}
|
||||
|
||||
/// Set true to show indicator at the input right.
|
||||
pub fn set_loading(&mut self, loading: bool, cx: &mut Context<Self>) {
|
||||
self.loading = loading;
|
||||
|
||||
@@ -405,13 +405,14 @@ impl Render for ResizablePanel {
|
||||
return div();
|
||||
}
|
||||
|
||||
let view = cx.entity().clone();
|
||||
let total_size = self
|
||||
.group
|
||||
.as_ref()
|
||||
.and_then(|group| group.upgrade())
|
||||
.map(|group| group.read(cx).total_size());
|
||||
|
||||
let view = cx.entity();
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_grow()
|
||||
|
||||
@@ -194,7 +194,7 @@ impl Root {
|
||||
}
|
||||
}
|
||||
|
||||
// Render Notification layer.
|
||||
/// Render Notification layer.
|
||||
pub fn render_notification_layer(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::display::ReadableProfile;
|
||||
use common::display::RenderedProfile;
|
||||
use gpui::{
|
||||
AnyElement, AnyView, App, ElementId, HighlightStyle, InteractiveText, IntoElement,
|
||||
SharedString, StyledText, UnderlineStyle, Window,
|
||||
|
||||
+15
-5
@@ -49,6 +49,10 @@ common:
|
||||
en: "Relay URL is not valid."
|
||||
recommended:
|
||||
en: "Recommended:"
|
||||
resend:
|
||||
en: "Resend"
|
||||
seen_on:
|
||||
en: "Seen on"
|
||||
|
||||
auto_update:
|
||||
updating:
|
||||
@@ -58,9 +62,11 @@ auto_update:
|
||||
|
||||
user:
|
||||
dark_mode:
|
||||
en: "Dark Mode"
|
||||
en: "Dark mode"
|
||||
settings:
|
||||
en: "Settings"
|
||||
reload_metadata:
|
||||
en: "Reload metadata"
|
||||
sign_out:
|
||||
en: "Sign out"
|
||||
|
||||
@@ -309,10 +315,6 @@ preferences:
|
||||
en: "Display"
|
||||
|
||||
compose:
|
||||
placeholder_npub:
|
||||
en: "npub or nprofile..."
|
||||
placeholder_title:
|
||||
en: "Family...(Optional)"
|
||||
create_dm_button:
|
||||
en: "Create DM"
|
||||
creating_dm_button:
|
||||
@@ -395,6 +397,14 @@ sidebar:
|
||||
en: "Incoming new conversations"
|
||||
trusted_contacts_tooltip:
|
||||
en: "Only show rooms from trusted contacts"
|
||||
no_requests:
|
||||
en: "No message requests"
|
||||
no_requests_label:
|
||||
en: "New message requests from people you don't know will appear here."
|
||||
no_conversations:
|
||||
en: "No conversations"
|
||||
no_conversations_label:
|
||||
en: "Start a conversation with someone to get started."
|
||||
|
||||
loading:
|
||||
label:
|
||||
|
||||
Reference in New Issue
Block a user