feat: sharpen chat experiences (#9)
* feat: add global account and refactor chat registry * chore: improve last seen * chore: reduce string alloc * wip: refactor room * chore: fix edit profile panel * chore: refactor open window in main * chore: refactor sidebar * chore: refactor room
This commit is contained in:
16
crates/account/Cargo.toml
Normal file
16
crates/account/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "account"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smol.workspace = true
|
||||
oneshot.workspace = true
|
||||
log.workspace = true
|
||||
1
crates/account/src/lib.rs
Normal file
1
crates/account/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod registry;
|
||||
117
crates/account/src/registry.rs
Normal file
117
crates/account/src/registry.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use anyhow::anyhow;
|
||||
use common::{
|
||||
constants::{ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID},
|
||||
profile::NostrProfile,
|
||||
};
|
||||
use gpui::{App, AppContext, AsyncApp, Context, Entity, Global, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::get_client;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
struct GlobalAccount(Entity<Account>);
|
||||
|
||||
impl Global for GlobalAccount {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Account {
|
||||
profile: NostrProfile,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
pub fn global(cx: &App) -> Option<Entity<Self>> {
|
||||
cx.try_global::<GlobalAccount>()
|
||||
.map(|model| model.0.clone())
|
||||
}
|
||||
|
||||
pub fn set_global(account: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAccount(account));
|
||||
}
|
||||
|
||||
pub fn login(signer: Arc<dyn NostrSigner>, cx: &AsyncApp) -> Task<Result<(), anyhow::Error>> {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Update nostr signer
|
||||
_ = client.set_signer(signer).await;
|
||||
// Verify nostr signer and get public key
|
||||
let result = async {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok::<_, anyhow::Error>(NostrProfile::new(public_key, metadata))
|
||||
}
|
||||
.await;
|
||||
|
||||
tx.send(result.ok()).ok();
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|cx| async move {
|
||||
if let Ok(Some(profile)) = rx.await {
|
||||
cx.update(|cx| {
|
||||
let this = cx.new(|cx| {
|
||||
let this = Account { profile };
|
||||
// Run initial sync data for this account
|
||||
if let Some(task) = this.sync(cx) {
|
||||
task.detach();
|
||||
}
|
||||
// Return
|
||||
this
|
||||
});
|
||||
|
||||
Self::set_global(this, cx)
|
||||
})
|
||||
} else {
|
||||
Err(anyhow!("Login failed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self) -> &NostrProfile {
|
||||
&self.profile
|
||||
}
|
||||
|
||||
fn sync(&self, cx: &mut Context<Self>) -> Option<Task<()>> {
|
||||
let client = get_client();
|
||||
let public_key = self.profile.public_key();
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
// Set the default options for this task
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
// Create a filter to get contact list
|
||||
let contact_list = Filter::new()
|
||||
.kind(Kind::ContactList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Err(e) = client.subscribe(contact_list, Some(opts)).await {
|
||||
log::error!("Failed to subscribe to contact list: {}", e);
|
||||
}
|
||||
|
||||
// Create a filter for getting all gift wrapped events send to current user
|
||||
let msg = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
let id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
|
||||
|
||||
if let Err(e) = client.subscribe_with_id(id, msg.clone(), Some(opts)).await {
|
||||
log::error!("Failed to subscribe to all messages: {}", e);
|
||||
}
|
||||
|
||||
// Create a filter to continuously receive new messages.
|
||||
let new_msg = msg.limit(0);
|
||||
let id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||
|
||||
if let Err(e) = client.subscribe_with_id(id, new_msg, None).await {
|
||||
log::error!("Failed to subscribe to new messages: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Some(task)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ ui = { path = "../ui" }
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
chats = { path = "../chats" }
|
||||
account = { path = "../account" }
|
||||
|
||||
gpui.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use asset::Assets;
|
||||
use chats::registry::ChatRegistry;
|
||||
use common::{
|
||||
constants::{ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID},
|
||||
profile::NostrProfile,
|
||||
use common::constants::{
|
||||
ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID,
|
||||
};
|
||||
use futures::{select, FutureExt};
|
||||
use gpui::{
|
||||
@@ -14,16 +13,16 @@ use gpui::{point, SharedString, TitlebarOptions};
|
||||
#[cfg(target_os = "linux")]
|
||||
use gpui::{WindowBackgroundAppearance, WindowDecorations};
|
||||
use log::{error, info};
|
||||
use nostr_sdk::SubscriptionId;
|
||||
use nostr_sdk::{
|
||||
pool::prelude::ReqExitPolicy, Client, Event, Filter, Keys, Kind, Metadata, PublicKey,
|
||||
RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions,
|
||||
pool::prelude::ReqExitPolicy, Client, Event, Filter, Keys, Kind, PublicKey, RelayMessage,
|
||||
RelayPoolNotification, SubscribeAutoCloseOptions,
|
||||
};
|
||||
use nostr_sdk::{prelude::NostrEventsDatabaseExt, FromBech32, SubscriptionId};
|
||||
use smol::Timer;
|
||||
use state::get_client;
|
||||
use std::{collections::HashSet, mem, sync::Arc, time::Duration};
|
||||
use ui::{theme::Theme, Root};
|
||||
use views::{app, onboarding, startup};
|
||||
use views::{app, onboarding};
|
||||
|
||||
mod asset;
|
||||
mod views;
|
||||
@@ -45,7 +44,7 @@ fn main() {
|
||||
// Enable logging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(2048);
|
||||
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(1024);
|
||||
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(100);
|
||||
|
||||
// Initialize nostr client
|
||||
@@ -176,31 +175,17 @@ fn main() {
|
||||
// Handle re-open window
|
||||
app.on_reopen(move |cx| {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(signer) = client.signer().await {
|
||||
if let Ok(public_key) = signer.get_public_key().await {
|
||||
let metadata =
|
||||
if let Ok(Some(metadata)) = client.database().metadata(public_key).await {
|
||||
metadata
|
||||
} else {
|
||||
Metadata::new()
|
||||
};
|
||||
|
||||
_ = tx.send(Some(NostrProfile::new(public_key, metadata)));
|
||||
} else {
|
||||
_ = tx.send(None);
|
||||
}
|
||||
} else {
|
||||
_ = tx.send(None);
|
||||
}
|
||||
let is_login = client.signer().await.is_ok();
|
||||
_ = tx.send(is_login);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|mut cx| async move {
|
||||
if let Ok(result) = rx.await {
|
||||
_ = restore_window(result, &mut cx).await;
|
||||
if let Ok(is_login) = rx.await {
|
||||
_ = restore_window(is_login, &mut cx).await;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
@@ -223,115 +208,90 @@ fn main() {
|
||||
items: vec![MenuItem::action("Quit", Quit)],
|
||||
}]);
|
||||
|
||||
// Open window with default options
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(APP_NAME)),
|
||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||
appears_transparent: true,
|
||||
}),
|
||||
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
|
||||
None,
|
||||
size(px(900.0), px(680.0)),
|
||||
cx,
|
||||
))),
|
||||
#[cfg(target_os = "linux")]
|
||||
window_background: WindowBackgroundAppearance::Transparent,
|
||||
#[cfg(target_os = "linux")]
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
kind: WindowKind::Normal,
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| {
|
||||
window.set_window_title(APP_NAME);
|
||||
window.set_app_id(APP_ID);
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
window
|
||||
.observe_window_appearance(|window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
let handle = window.window_handle();
|
||||
let root = cx.new(|cx| Root::new(startup::init(window, cx).into(), window, cx));
|
||||
|
||||
let task = cx.read_credentials(KEYRING_SERVICE);
|
||||
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
|
||||
|
||||
// Read credential in OS Keyring
|
||||
cx.background_spawn(async {
|
||||
let profile = if let Ok(Some((npub, secret))) = task.await {
|
||||
let public_key = PublicKey::from_bech32(&npub).unwrap();
|
||||
let secret_hex = String::from_utf8(secret).unwrap();
|
||||
let keys = Keys::parse(&secret_hex).unwrap();
|
||||
|
||||
// Update nostr signer
|
||||
_ = client.set_signer(keys).await;
|
||||
|
||||
// Get user's metadata
|
||||
let metadata = if let Ok(Some(metadata)) =
|
||||
client.database().metadata(public_key).await
|
||||
{
|
||||
metadata
|
||||
} else {
|
||||
Metadata::new()
|
||||
// Spawn a task to handle events from nostr channel
|
||||
cx.spawn(|cx| async move {
|
||||
while let Ok(signal) = event_rx.recv().await {
|
||||
cx.update(|cx| {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
match signal {
|
||||
Signal::Eose => chats.update(cx, |this, cx| this.load_chat_rooms(cx)),
|
||||
Signal::Event(event) => {
|
||||
chats.update(cx, |this, cx| this.push_message(event, cx))
|
||||
}
|
||||
};
|
||||
|
||||
Some(NostrProfile::new(public_key, metadata))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
_ = tx.send(profile)
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Set root view based on credential status
|
||||
cx.spawn(|mut cx| async move {
|
||||
if let Ok(Some(profile)) = rx.await {
|
||||
_ = cx.update_window(handle, |_, window, cx| {
|
||||
window.replace_root(cx, |window, cx| {
|
||||
Root::new(app::init(profile, window, cx).into(), window, cx)
|
||||
});
|
||||
});
|
||||
} else {
|
||||
_ = cx.update_window(handle, |_, window, cx| {
|
||||
window.replace_root(cx, |window, cx| {
|
||||
Root::new(onboarding::init(window, cx).into(), window, cx)
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|cx| async move {
|
||||
while let Ok(signal) = event_rx.recv().await {
|
||||
cx.update(|cx| {
|
||||
match signal {
|
||||
Signal::Eose => {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
chats.update(cx, |this, cx| this.load_chat_rooms(cx))
|
||||
}
|
||||
}
|
||||
Signal::Event(event) => {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
chats.update(cx, |this, cx| this.push_message(event, cx))
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
// Set up the window options
|
||||
let window_opts = WindowOptions {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(APP_NAME)),
|
||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||
appears_transparent: true,
|
||||
}),
|
||||
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
|
||||
None,
|
||||
size(px(900.0), px(680.0)),
|
||||
cx,
|
||||
))),
|
||||
#[cfg(target_os = "linux")]
|
||||
window_background: WindowBackgroundAppearance::Transparent,
|
||||
#[cfg(target_os = "linux")]
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
kind: WindowKind::Normal,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
root
|
||||
},
|
||||
)
|
||||
.expect("System error. Please re-open the app.");
|
||||
// Create a task to read credentials from the keyring service
|
||||
let task = cx.read_credentials(KEYRING_SERVICE);
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
|
||||
// Read credential in OS Keyring
|
||||
cx.background_spawn(async {
|
||||
let is_ready = if let Ok(Some((_, secret))) = task.await {
|
||||
let result = async {
|
||||
let secret_hex = String::from_utf8(secret)?;
|
||||
let keys = Keys::parse(&secret_hex)?;
|
||||
|
||||
// Update nostr signer
|
||||
client.set_signer(keys).await;
|
||||
|
||||
Ok::<_, anyhow::Error>(true)
|
||||
}
|
||||
.await;
|
||||
|
||||
result.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
_ = tx.send(is_ready)
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|cx| async move {
|
||||
if let Ok(is_ready) = rx.await {
|
||||
if is_ready {
|
||||
// Open a App window
|
||||
cx.open_window(window_opts, |window, cx| {
|
||||
cx.new(|cx| Root::new(app::init(window, cx).into(), window, cx))
|
||||
})
|
||||
.expect("Failed to open window");
|
||||
} else {
|
||||
// Open a Onboarding window
|
||||
cx.open_window(window_opts, |window, cx| {
|
||||
cx.new(|cx| Root::new(onboarding::init(window, cx).into(), window, cx))
|
||||
})
|
||||
.expect("Failed to open window");
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -347,7 +307,7 @@ async fn sync_metadata(client: &Client, buffer: HashSet<PublicKey>) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_window(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> anyhow::Result<()> {
|
||||
async fn restore_window(is_login: bool, cx: &mut AsyncApp) -> anyhow::Result<()> {
|
||||
let opts = cx
|
||||
.update(|cx| WindowOptions {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -370,7 +330,7 @@ async fn restore_window(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> any
|
||||
})
|
||||
.expect("Failed to set window options.");
|
||||
|
||||
if let Some(profile) = profile {
|
||||
if is_login {
|
||||
_ = cx.open_window(opts, |window, cx| {
|
||||
window.set_window_title(APP_NAME);
|
||||
window.set_app_id(APP_ID);
|
||||
@@ -382,7 +342,7 @@ async fn restore_window(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> any
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.new(|cx| Root::new(app::init(profile, window, cx).into(), window, cx))
|
||||
cx.new(|cx| Root::new(app::init(window, cx).into(), window, cx))
|
||||
});
|
||||
} else {
|
||||
_ = cx.open_window(opts, |window, cx| {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use common::profile::NostrProfile;
|
||||
use account::registry::Account;
|
||||
use gpui::{
|
||||
actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis,
|
||||
Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled,
|
||||
@@ -38,21 +38,22 @@ impl AddPanel {
|
||||
}
|
||||
}
|
||||
|
||||
// Dock actions
|
||||
impl_internal_actions!(dock, [AddPanel]);
|
||||
// Account actions
|
||||
actions!(account, [Logout]);
|
||||
|
||||
pub fn init(account: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<AppView> {
|
||||
AppView::new(account, window, cx)
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<AppView> {
|
||||
AppView::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct AppView {
|
||||
account: NostrProfile,
|
||||
relays: Entity<Option<Vec<String>>>,
|
||||
dock: Entity<DockArea>,
|
||||
}
|
||||
|
||||
impl AppView {
|
||||
pub fn new(account: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
// Initialize dock layout
|
||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||
let weak_dock = dock.downgrade();
|
||||
@@ -83,76 +84,74 @@ impl AppView {
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let public_key = account.public_key();
|
||||
let relays = cx.new(|_| None);
|
||||
let async_relays = relays.downgrade();
|
||||
let this = Self { relays, dock };
|
||||
|
||||
// Check user's messaging relays and determine user is ready for NIP17 or not.
|
||||
// If not, show the setup modal and instruct user setup inbox relays
|
||||
let client = get_client();
|
||||
let window_handle = window.window_handle();
|
||||
let (tx, rx) = oneshot::channel::<Option<Vec<String>>>();
|
||||
|
||||
let this = Self {
|
||||
account,
|
||||
relays,
|
||||
dock,
|
||||
};
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
let relays = if let Ok(events) = client.database().query(filter).await {
|
||||
if let Some(event) = events.first_owned() {
|
||||
Some(
|
||||
event
|
||||
.tags
|
||||
.filter_standardized(TagKind::Relay)
|
||||
.filter_map(|t| match t {
|
||||
TagStandard::Relay(url) => Some(url.to_string()),
|
||||
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
_ = tx.send(relays);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if let Ok(result) = rx.await {
|
||||
if let Some(relays) = result {
|
||||
_ = cx.update(|cx| {
|
||||
_ = async_relays.update(cx, |this, cx| {
|
||||
*this = Some(relays);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
this.update(cx, |this: &mut Self, cx| {
|
||||
this.render_setup_relays(window, cx)
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
this.verify_user_relays(window, cx);
|
||||
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_user_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(account) = Account::global(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let public_key = account.read(cx).get().public_key();
|
||||
let client = get_client();
|
||||
let window_handle = window.window_handle();
|
||||
let (tx, rx) = oneshot::channel::<Option<Vec<String>>>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
let relays = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|events| events.first_owned())
|
||||
.map(|event| {
|
||||
event
|
||||
.tags
|
||||
.filter_standardized(TagKind::Relay)
|
||||
.filter_map(|t| match t {
|
||||
TagStandard::Relay(url) => Some(url.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
_ = tx.send(relays);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if let Ok(Some(relays)) = rx.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
let relays = cx.new(|_| Some(relays));
|
||||
this.relays = relays;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
this.update(cx, |this: &mut Self, cx| {
|
||||
this.render_setup_relays(window, cx)
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_setup_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let relays = cx.new(|cx| Relays::new(None, window, cx));
|
||||
|
||||
@@ -254,18 +253,22 @@ impl AppView {
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_account(&self) -> impl IntoElement {
|
||||
fn render_account(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Button::new("account")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.reverse()
|
||||
.icon(Icon::new(IconName::ChevronDownSmall))
|
||||
.child(
|
||||
img(self.account.avatar())
|
||||
.size_5()
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
.when_some(Account::global(cx), |this, account| {
|
||||
let profile = account.read(cx).get();
|
||||
|
||||
this.child(
|
||||
img(profile.avatar())
|
||||
.size_5()
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
})
|
||||
.popup_menu(move |this, _, _cx| {
|
||||
this.menu(
|
||||
"Profile",
|
||||
@@ -286,16 +289,19 @@ impl AppView {
|
||||
|
||||
fn on_panel_action(&mut self, action: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match &action.panel {
|
||||
PanelKind::Room(id) => match chat::init(id, window, cx) {
|
||||
Ok(panel) => {
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
PanelKind::Room(id) => {
|
||||
// User must be logged in to open a room
|
||||
match chat::init(id, window, cx) {
|
||||
Ok(panel) => {
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
}
|
||||
Err(e) => window.push_notification(e.to_string(), cx),
|
||||
}
|
||||
Err(e) => window.push_notification(e.to_string(), cx),
|
||||
},
|
||||
}
|
||||
PanelKind::Profile => {
|
||||
let panel = Arc::new(profile::init(self.account.clone(), window, cx));
|
||||
let panel = profile::init(window, cx);
|
||||
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
@@ -319,8 +325,13 @@ impl AppView {
|
||||
}
|
||||
|
||||
fn on_logout_action(&mut self, _action: &Logout, window: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.background_spawn(async move { get_client().reset().await })
|
||||
.detach();
|
||||
let client = get_client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Reset nostr client
|
||||
client.reset().await
|
||||
})
|
||||
.detach();
|
||||
|
||||
window.replace_root(cx, |window, cx| {
|
||||
Root::new(onboarding::init(window, cx).into(), window, cx)
|
||||
@@ -353,7 +364,7 @@ impl Render for AppView {
|
||||
.px_2()
|
||||
.child(self.render_appearance_button(window, cx))
|
||||
.child(self.render_relays_button(window, cx))
|
||||
.child(self.render_account()),
|
||||
.child(self.render_account(cx)),
|
||||
),
|
||||
)
|
||||
.child(self.dock.clone())
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use account::registry::Account;
|
||||
use anyhow::anyhow;
|
||||
use async_utility::task::spawn;
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
@@ -58,14 +59,12 @@ struct ParsedMessage {
|
||||
|
||||
impl ParsedMessage {
|
||||
pub fn new(profile: &NostrProfile, content: &str, created_at: Timestamp) -> Self {
|
||||
let avatar = profile.avatar().into();
|
||||
let display_name = profile.name().into();
|
||||
let content = SharedString::new(content);
|
||||
let created_at = LastSeen(created_at).human_readable();
|
||||
|
||||
Self {
|
||||
avatar,
|
||||
display_name,
|
||||
avatar: profile.avatar(),
|
||||
display_name: profile.name(),
|
||||
created_at,
|
||||
content,
|
||||
}
|
||||
@@ -96,13 +95,10 @@ impl Message {
|
||||
pub struct Chat {
|
||||
// Panel
|
||||
id: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
// Chat Room
|
||||
room: WeakEntity<Room>,
|
||||
messages: Entity<Vec<Message>>,
|
||||
new_messages: Option<WeakEntity<Vec<Event>>>,
|
||||
list_state: ListState,
|
||||
subscriptions: Vec<Subscription>,
|
||||
// New Message
|
||||
@@ -119,21 +115,16 @@ impl Chat {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let new_messages = room
|
||||
.read_with(cx, |this, _| this.new_messages.downgrade())
|
||||
.ok();
|
||||
let messages = cx.new(|_| vec![Message::placeholder()]);
|
||||
let attaches = cx.new(|_| None);
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.appearance(false)
|
||||
.text_size(ui::Size::Small)
|
||||
.placeholder("Message...")
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let messages = cx.new(|_| vec![Message::placeholder()]);
|
||||
let attaches = cx.new(|_| None);
|
||||
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.appearance(false)
|
||||
.text_size(ui::Size::Small)
|
||||
.placeholder("Message...")
|
||||
});
|
||||
|
||||
let subscriptions = vec![cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
@@ -157,13 +148,10 @@ impl Chat {
|
||||
});
|
||||
|
||||
let mut this = Self {
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
is_uploading: false,
|
||||
id: id.to_string().into(),
|
||||
room,
|
||||
new_messages,
|
||||
messages,
|
||||
list_state,
|
||||
input,
|
||||
@@ -189,11 +177,16 @@ impl Chat {
|
||||
return;
|
||||
};
|
||||
|
||||
let room = model.read(cx);
|
||||
let pubkeys: Vec<PublicKey> = room.members.iter().map(|m| m.public_key()).collect();
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Vec<(PublicKey, bool)>>();
|
||||
|
||||
let pubkeys: Vec<PublicKey> = model
|
||||
.read(cx)
|
||||
.members
|
||||
.iter()
|
||||
.map(|m| m.public_key())
|
||||
.collect();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut result = Vec::new();
|
||||
|
||||
@@ -224,7 +217,7 @@ impl Chat {
|
||||
if !item.1 {
|
||||
let name = this
|
||||
.room
|
||||
.read_with(cx, |this, _| this.name())
|
||||
.read_with(cx, |this, _| this.name().unwrap_or("Unnamed".into()))
|
||||
.unwrap_or("Unnamed".into());
|
||||
|
||||
this.push_system_message(
|
||||
@@ -245,35 +238,25 @@ impl Chat {
|
||||
return;
|
||||
};
|
||||
|
||||
let room = model.read(cx);
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Events>();
|
||||
|
||||
let room = model.read(cx);
|
||||
let pubkeys = room
|
||||
.members
|
||||
.iter()
|
||||
.map(|m| m.public_key())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let recv = Filter::new()
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.author(room.owner.public_key())
|
||||
.pubkeys(pubkeys.iter().copied());
|
||||
|
||||
let send = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.authors(pubkeys)
|
||||
.pubkey(room.owner.public_key());
|
||||
.authors(pubkeys.iter().copied())
|
||||
.pubkeys(pubkeys);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let Ok(recv_events) = client.database().query(recv).await else {
|
||||
let Ok(events) = client.database().query(filter).await else {
|
||||
return;
|
||||
};
|
||||
let Ok(send_events) = client.database().query(send).await else {
|
||||
return;
|
||||
};
|
||||
let events = recv_events.merge(send_events);
|
||||
|
||||
_ = tx.send(events);
|
||||
})
|
||||
.detach();
|
||||
@@ -303,13 +286,13 @@ impl Chat {
|
||||
}
|
||||
|
||||
fn push_message(&self, content: String, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
let Some(account) = Account::global(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let room = model.read(cx);
|
||||
let message = Message::new(ParsedMessage::new(&room.owner, &content, Timestamp::now()));
|
||||
let profile = account.read(cx).get();
|
||||
let message = Message::new(ParsedMessage::new(profile, &content, Timestamp::now()));
|
||||
|
||||
// Update message list
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
@@ -333,39 +316,40 @@ impl Chat {
|
||||
return;
|
||||
};
|
||||
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let room = model.read(cx);
|
||||
let pubkeys = room.pubkeys();
|
||||
let pubkeys = room
|
||||
.members
|
||||
.iter()
|
||||
.map(|m| m.public_key())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let (messages, total) = {
|
||||
let old_len = self.messages.read(cx).len();
|
||||
|
||||
let (messages, new_len) = {
|
||||
let items: Vec<Message> = events
|
||||
.into_iter()
|
||||
.sorted_by_key(|ev| ev.created_at)
|
||||
.filter_map(|ev| {
|
||||
let mut other_pubkeys: Vec<_> = ev.tags.public_keys().copied().collect();
|
||||
let mut other_pubkeys = ev.tags.public_keys().copied().collect::<Vec<_>>();
|
||||
other_pubkeys.push(ev.pubkey);
|
||||
|
||||
if compare(&other_pubkeys, &pubkeys) {
|
||||
let member = if let Some(member) =
|
||||
room.members.iter().find(|&m| m.public_key() == ev.pubkey)
|
||||
{
|
||||
member.to_owned()
|
||||
} else {
|
||||
room.owner.to_owned()
|
||||
};
|
||||
|
||||
let message =
|
||||
Message::new(ParsedMessage::new(&member, &ev.content, ev.created_at));
|
||||
|
||||
Some(message)
|
||||
} else {
|
||||
None
|
||||
if !compare(&other_pubkeys, &pubkeys) {
|
||||
return None;
|
||||
}
|
||||
|
||||
room.members
|
||||
.iter()
|
||||
.find(|m| m.public_key() == ev.pubkey)
|
||||
.map(|member| {
|
||||
Message::new(ParsedMessage::new(member, &ev.content, ev.created_at))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let total = items.len();
|
||||
|
||||
(items, total)
|
||||
// Used for update list state
|
||||
let new_len = items.len();
|
||||
|
||||
(items, new_len)
|
||||
};
|
||||
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
@@ -373,25 +357,27 @@ impl Chat {
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.list_state.splice(old_len..old_len, total);
|
||||
self.list_state.splice(old_len..old_len, new_len);
|
||||
}
|
||||
|
||||
fn load_new_messages(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(Some(model)) = self.new_messages.as_ref().map(|state| state.upgrade()) else {
|
||||
let Some(room) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let subscription = cx.observe(&model, |view, this, cx| {
|
||||
let Some(model) = view.room.upgrade() else {
|
||||
let subscription = cx.observe(&room, |view, this, cx| {
|
||||
let room = this.read(cx);
|
||||
|
||||
if room.new_messages.is_empty() {
|
||||
return;
|
||||
};
|
||||
|
||||
let room = model.read(cx);
|
||||
let old_messages = view.messages.read(cx);
|
||||
let old_len = old_messages.len();
|
||||
|
||||
let items: Vec<Message> = this
|
||||
.read(cx)
|
||||
.new_messages
|
||||
.iter()
|
||||
.filter_map(|event| {
|
||||
if let Some(profile) = room.member(&event.pubkey) {
|
||||
@@ -466,29 +452,33 @@ impl Chat {
|
||||
this.set_disabled(true, window, cx);
|
||||
});
|
||||
|
||||
let room = model.read(cx);
|
||||
// let subject = Tag::from_standardized_without_cell(TagStandard::Subject(room.title.clone()));
|
||||
let pubkeys = room.public_keys();
|
||||
let async_content = content.clone().to_string();
|
||||
|
||||
let client = get_client();
|
||||
let window_handle = window.window_handle();
|
||||
let (tx, rx) = oneshot::channel::<Vec<Error>>();
|
||||
|
||||
let room = model.read(cx);
|
||||
let pubkeys = room.pubkeys();
|
||||
let async_content = content.clone().to_string();
|
||||
let tags: Vec<Tag> = room
|
||||
.pubkeys()
|
||||
.iter()
|
||||
.filter_map(|pubkey| {
|
||||
if pubkey != &room.owner.public_key() {
|
||||
Some(Tag::public_key(*pubkey))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Send message to all pubkeys
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await.unwrap();
|
||||
let public_key = signer.get_public_key().await.unwrap();
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let tags: Vec<Tag> = pubkeys
|
||||
.iter()
|
||||
.filter_map(|pubkey| {
|
||||
if pubkey != &public_key {
|
||||
Some(Tag::public_key(*pubkey))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for pubkey in pubkeys.iter() {
|
||||
if let Err(e) = client
|
||||
.send_private_msg(*pubkey, &async_content, tags.clone())
|
||||
@@ -709,9 +699,8 @@ impl Panel for Chat {
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
self.room
|
||||
.read_with(cx, |this, _cx| {
|
||||
let name = this.name();
|
||||
let facepill: Vec<String> =
|
||||
.read_with(cx, |this, _| {
|
||||
let facepill: Vec<SharedString> =
|
||||
this.members.iter().map(|member| member.avatar()).collect();
|
||||
|
||||
div()
|
||||
@@ -733,20 +722,12 @@ impl Panel for Chat {
|
||||
)
|
||||
})),
|
||||
)
|
||||
.child(name)
|
||||
.when_some(this.name(), |this, name| this.child(name))
|
||||
.into_any()
|
||||
})
|
||||
.unwrap_or("Unnamed".into_any())
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
@@ -8,4 +8,3 @@ mod welcome;
|
||||
|
||||
pub mod app;
|
||||
pub mod onboarding;
|
||||
pub mod startup;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use common::{profile::NostrProfile, qr::create_qr, utils::preload};
|
||||
use account::registry::Account;
|
||||
use common::qr::create_qr;
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, relative, svg, App, AppContext, ClipboardItem, Context, Div,
|
||||
Entity, IntoElement, ParentElement, Render, Styled, Subscription, Window,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use state::get_client;
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
use ui::{
|
||||
button::{Button, ButtonCustomVariant, ButtonVariants},
|
||||
input::{InputEvent, TextInput},
|
||||
@@ -16,8 +16,12 @@ use ui::{
|
||||
|
||||
use super::app;
|
||||
|
||||
const LOGO_URL: &str = "brand/coop.svg";
|
||||
const TITLE: &str = "Welcome to Coop!";
|
||||
const SUBTITLE: &str = "A Nostr client for secure communication.";
|
||||
const ALPHA_MESSAGE: &str =
|
||||
"Coop is in the alpha stage of development; It may contain bugs, unfinished features, or unexpected behavior.";
|
||||
|
||||
const JOIN_URL: &str = "https://start.njump.me/";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
|
||||
@@ -62,7 +66,7 @@ impl Onboarding {
|
||||
window,
|
||||
move |this: &mut Self, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
this.privkey_login(window, cx);
|
||||
this.login_with_private_key(window, cx);
|
||||
}
|
||||
},
|
||||
)];
|
||||
@@ -80,68 +84,50 @@ impl Onboarding {
|
||||
})
|
||||
}
|
||||
|
||||
fn use_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn login_with_nostr_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let uri = self.connect_uri.clone();
|
||||
let app_keys = self.app_keys.clone();
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
self.use_connect = true;
|
||||
cx.notify();
|
||||
// Show QR Code for login with Nostr Connect
|
||||
self.use_connect(window, cx);
|
||||
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
let (tx, rx) = oneshot::channel::<NostrProfile>();
|
||||
// Wait for connection
|
||||
let (tx, rx) = oneshot::channel::<NostrConnect>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(signer) = NostrConnect::new(uri, app_keys, Duration::from_secs(300), None)
|
||||
{
|
||||
if let Ok(uri) = signer.bunker_uri().await {
|
||||
let client = get_client();
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(signer) = NostrConnect::new(uri, app_keys, Duration::from_secs(300), None) {
|
||||
tx.send(signer).ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
if let Some(public_key) = uri.remote_signer_public_key() {
|
||||
let metadata = client
|
||||
.fetch_metadata(*public_key, Duration::from_secs(2))
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(signer) = rx.await {
|
||||
cx.spawn(|mut cx| async move {
|
||||
let signer = Arc::new(signer);
|
||||
|
||||
if tx.send(NostrProfile::new(*public_key, metadata)).is_ok() {
|
||||
_ = client.set_signer(signer).await;
|
||||
_ = preload(client, *public_key).await;
|
||||
}
|
||||
}
|
||||
if Account::login(signer, &cx).await.is_ok() {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
window.replace_root(cx, |window, cx| {
|
||||
Root::new(app::init(window, cx).into(), window, cx)
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
if let Ok(profile) = rx.await {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
window.replace_root(cx, |window, cx| {
|
||||
Root::new(app::init(profile, window, cx).into(), window, cx)
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
} else {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn use_privkey(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.use_privkey = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn reset(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.use_privkey = false;
|
||||
self.use_connect = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn privkey_login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn login_with_private_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.nsec_input.read(cx).text().to_string();
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
@@ -160,37 +146,47 @@ impl Onboarding {
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<NostrProfile>();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let signer = Arc::new(keys);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(public_key) = keys.get_public_key().await {
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
|
||||
if tx.send(NostrProfile::new(public_key, metadata)).is_ok() {
|
||||
_ = client.set_signer(keys).await;
|
||||
_ = preload(client, public_key).await;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
if let Ok(profile) = rx.await {
|
||||
if Account::login(signer, &cx).await.is_ok() {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
window.replace_root(cx, |window, cx| {
|
||||
Root::new(app::init(profile, window, cx).into(), window, cx)
|
||||
Root::new(app::init(window, cx).into(), window, cx)
|
||||
});
|
||||
})
|
||||
} else {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn use_connect(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.use_connect = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn use_privkey(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.use_privkey = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn reset(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.use_privkey = false;
|
||||
self.use_connect = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_selection(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
|
||||
div()
|
||||
.w_full()
|
||||
@@ -205,7 +201,7 @@ impl Onboarding {
|
||||
.primary()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.use_connect(window, cx);
|
||||
this.login_with_nostr_connect(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
@@ -331,7 +327,7 @@ impl Onboarding {
|
||||
.w_full()
|
||||
.loading(self.is_loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.privkey_login(window, cx);
|
||||
this.login_with_private_key(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
@@ -368,7 +364,7 @@ impl Render for Onboarding {
|
||||
.gap_4()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.path(LOGO_URL)
|
||||
.size_12()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
@@ -380,7 +376,7 @@ impl Render for Onboarding {
|
||||
.text_lg()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("Welcome to Coop!"),
|
||||
.child(TITLE),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -388,19 +384,19 @@ impl Render for Onboarding {
|
||||
.text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.child("A Nostr client for secure communication."),
|
||||
.child(SUBTITLE),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(div().w_72().map(|_| {
|
||||
if self.use_privkey {
|
||||
self.render_privkey_login(cx)
|
||||
} else if self.use_connect {
|
||||
self.render_connect_login(cx)
|
||||
} else {
|
||||
self.render_selection(window, cx)
|
||||
}
|
||||
})),
|
||||
.child(
|
||||
div()
|
||||
.w_72()
|
||||
.map(|_| match (self.use_privkey, self.use_connect) {
|
||||
(true, _) => self.render_privkey_login(cx),
|
||||
(_, true) => self.render_connect_login(cx),
|
||||
_ => self.render_selection(window, cx),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -411,8 +407,8 @@ impl Render for Onboarding {
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.text_align(gpui::TextAlign::Center)
|
||||
.child(ALPHA_MESSAGE),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use async_utility::task::spawn;
|
||||
use common::{constants::IMAGE_SERVICE, profile::NostrProfile, utils::nip96_upload};
|
||||
use common::{constants::IMAGE_SERVICE, utils::nip96_upload};
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render,
|
||||
@@ -8,7 +8,7 @@ use gpui::{
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::fs;
|
||||
use state::get_client;
|
||||
use std::str::FromStr;
|
||||
use std::{str::FromStr, sync::Arc, time::Duration};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
@@ -17,12 +17,12 @@ use ui::{
|
||||
ContextModal, Disableable, Sizable, Size,
|
||||
};
|
||||
|
||||
pub fn init(profile: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Profile> {
|
||||
Profile::new(profile, window, cx)
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Arc<Entity<Profile>> {
|
||||
Arc::new(Profile::new(window, cx))
|
||||
}
|
||||
|
||||
pub struct Profile {
|
||||
profile: NostrProfile,
|
||||
profile: Option<Metadata>,
|
||||
// Form
|
||||
name_input: Entity<TextInput>,
|
||||
avatar_input: Entity<TextInput>,
|
||||
@@ -32,60 +32,108 @@ pub struct Profile {
|
||||
is_submitting: bool,
|
||||
// Panel
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(mut profile: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
let name_input = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx).text_size(Size::XSmall);
|
||||
if let Some(name) = profile.metadata().display_name.as_ref() {
|
||||
input.set_text(name, window, cx);
|
||||
}
|
||||
input
|
||||
});
|
||||
let avatar_input = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx).text_size(Size::XSmall).small();
|
||||
if let Some(picture) = profile.metadata().picture.as_ref() {
|
||||
input.set_text(picture, window, cx);
|
||||
}
|
||||
input
|
||||
});
|
||||
let bio_input = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx)
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.multi_line();
|
||||
if let Some(about) = profile.metadata().about.as_ref() {
|
||||
input.set_text(about, window, cx);
|
||||
} else {
|
||||
input.set_placeholder("A short introduce about you.");
|
||||
}
|
||||
input
|
||||
});
|
||||
let website_input = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx).text_size(Size::XSmall);
|
||||
if let Some(website) = profile.metadata().website.as_ref() {
|
||||
input.set_text(website, window, cx);
|
||||
} else {
|
||||
input.set_placeholder("https://your-website.com");
|
||||
}
|
||||
input
|
||||
.placeholder("Alice")
|
||||
});
|
||||
|
||||
cx.new(|cx| Self {
|
||||
profile,
|
||||
name_input,
|
||||
avatar_input,
|
||||
bio_input,
|
||||
website_input,
|
||||
is_loading: false,
|
||||
is_submitting: false,
|
||||
name: "Profile".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
let avatar_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.small()
|
||||
.placeholder("https://example.com/avatar.png")
|
||||
});
|
||||
|
||||
let website_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.placeholder("https://your-website.com")
|
||||
});
|
||||
|
||||
let bio_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.multi_line()
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let this = Self {
|
||||
name_input,
|
||||
avatar_input,
|
||||
bio_input,
|
||||
website_input,
|
||||
profile: None,
|
||||
is_loading: false,
|
||||
is_submitting: false,
|
||||
name: "Profile".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
};
|
||||
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Option<Metadata>>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let result = async {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?;
|
||||
|
||||
Ok::<_, anyhow::Error>(metadata)
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Ok(metadata) = result {
|
||||
_ = tx.send(Some(metadata));
|
||||
} else {
|
||||
_ = tx.send(None);
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if let Ok(Some(metadata)) = rx.await {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this: &mut Profile, cx| {
|
||||
this.avatar_input.update(cx, |this, cx| {
|
||||
if let Some(avatar) = metadata.picture.as_ref() {
|
||||
this.set_text(avatar, window, cx);
|
||||
}
|
||||
});
|
||||
this.bio_input.update(cx, |this, cx| {
|
||||
if let Some(bio) = metadata.about.as_ref() {
|
||||
this.set_text(bio, window, cx);
|
||||
}
|
||||
});
|
||||
this.name_input.update(cx, |this, cx| {
|
||||
if let Some(display_name) = metadata.display_name.as_ref() {
|
||||
this.set_text(display_name, window, cx);
|
||||
}
|
||||
});
|
||||
this.website_input.update(cx, |this, cx| {
|
||||
if let Some(website) = metadata.website.as_ref() {
|
||||
this.set_text(website, window, cx);
|
||||
}
|
||||
});
|
||||
this.profile = Some(metadata);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,12 +212,13 @@ impl Profile {
|
||||
let bio = self.bio_input.read(cx).text().to_string();
|
||||
let website = self.website_input.read(cx).text().to_string();
|
||||
|
||||
let mut new_metadata = self
|
||||
.profile
|
||||
.metadata()
|
||||
.to_owned()
|
||||
.display_name(name)
|
||||
.about(bio);
|
||||
let old_metadata = if let Some(metadata) = self.profile.as_ref() {
|
||||
metadata.clone()
|
||||
} else {
|
||||
Metadata::default()
|
||||
};
|
||||
|
||||
let mut new_metadata = old_metadata.display_name(name).about(bio);
|
||||
|
||||
if let Ok(url) = Url::from_str(&avatar) {
|
||||
new_metadata = new_metadata.picture(url);
|
||||
@@ -221,14 +270,6 @@ impl Panel for Profile {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
use async_utility::task::spawn;
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
use common::{
|
||||
profile::NostrProfile,
|
||||
utils::{random_name, signer_public_key},
|
||||
};
|
||||
use common::{profile::NostrProfile, utils::random_name};
|
||||
use gpui::{
|
||||
div, img, impl_internal_actions, prelude::FluentBuilder, px, relative, uniform_list, App,
|
||||
AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement,
|
||||
@@ -86,15 +83,16 @@ impl Compose {
|
||||
let (tx, rx) = oneshot::channel::<Vec<NostrProfile>>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(public_key) = signer_public_key(client).await {
|
||||
if let Ok(profiles) = client.database().contacts(public_key).await {
|
||||
let members: Vec<NostrProfile> = profiles
|
||||
.into_iter()
|
||||
.map(|profile| NostrProfile::new(profile.public_key(), profile.metadata()))
|
||||
.collect();
|
||||
let signer = client.signer().await.unwrap();
|
||||
let public_key = signer.get_public_key().await.unwrap();
|
||||
|
||||
_ = tx.send(members);
|
||||
}
|
||||
if let Ok(profiles) = client.database().contacts(public_key).await {
|
||||
let members: Vec<NostrProfile> = profiles
|
||||
.into_iter()
|
||||
.map(|profile| NostrProfile::new(profile.public_key(), profile.metadata()))
|
||||
.collect();
|
||||
|
||||
_ = tx.send(members);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
@@ -178,17 +176,19 @@ impl Compose {
|
||||
});
|
||||
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
let room = Room::parse(&event, cx);
|
||||
let room = Room::new(&event, cx);
|
||||
|
||||
chats.update(cx, |state, cx| match state.new_room(room, cx) {
|
||||
Ok(_) => {
|
||||
// TODO: open chat panel
|
||||
window.close_modal(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_error(Some(e.to_string().into()), cx);
|
||||
});
|
||||
chats.update(cx, |state, cx| {
|
||||
match state.push_room(room, cx) {
|
||||
Ok(_) => {
|
||||
// TODO: open chat panel
|
||||
window.close_modal(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_error(Some(e.to_string().into()), cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
use crate::views::app::{AddPanel, PanelKind};
|
||||
use chats::registry::ChatRegistry;
|
||||
use gpui::{
|
||||
div, img, percentage, prelude::FluentBuilder, px, relative, Context, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
|
||||
TextAlign, Window,
|
||||
};
|
||||
use ui::{
|
||||
dock_area::dock::DockPlacement,
|
||||
skeleton::Skeleton,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
v_flex, Collapsible, Icon, IconName, StyledExt,
|
||||
};
|
||||
|
||||
pub struct Inbox {
|
||||
label: SharedString,
|
||||
is_collapsed: bool,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
pub fn new(_window: &mut Window, _cx: &mut Context<'_, Self>) -> Self {
|
||||
Self {
|
||||
label: "Inbox".into(),
|
||||
is_collapsed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_skeleton(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
|
||||
(0..total).map(|_| {
|
||||
div()
|
||||
.h_8()
|
||||
.px_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
|
||||
.child(Skeleton::new().w_20().h_3().rounded_sm())
|
||||
})
|
||||
}
|
||||
|
||||
fn render_item(&self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
div().map(|this| {
|
||||
let state = chats.read(cx);
|
||||
let rooms = state.rooms();
|
||||
|
||||
if state.is_loading() {
|
||||
this.children(self.render_skeleton(5))
|
||||
} else if rooms.is_empty() {
|
||||
this.px_1()
|
||||
.w_full()
|
||||
.h_20()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_align(TextAlign::Center)
|
||||
.rounded(px(cx.theme().radius))
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("No chats"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child("Recent chats will appear here."),
|
||||
)
|
||||
} else {
|
||||
this.children(rooms.iter().map(|model| {
|
||||
let room = model.read(cx);
|
||||
let room_id: SharedString = room.id.to_string().into();
|
||||
|
||||
div()
|
||||
.id(room_id)
|
||||
.h_8()
|
||||
.px_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.text_xs()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)))
|
||||
.child(div().flex_1().truncate().font_medium().map(|this| {
|
||||
if room.is_group {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(img("brand/avatar.png").size_6().rounded_full())
|
||||
.child(room.name())
|
||||
} else {
|
||||
this.when_some(room.members.first(), |this, sender| {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
img(sender.avatar())
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.child(sender.name())
|
||||
})
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(room.last_seen.ago()),
|
||||
)
|
||||
.on_click({
|
||||
let id = room.id;
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.action(id, window, cx);
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
div().children(self.render_skeleton(5))
|
||||
}
|
||||
}
|
||||
|
||||
fn action(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.dispatch_action(
|
||||
Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Collapsible for Inbox {
|
||||
fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.is_collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_collapsed(&self) -> bool {
|
||||
self.is_collapsed
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Inbox {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id("inbox")
|
||||
.h_7()
|
||||
.px_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.size_6()
|
||||
.when(self.is_collapsed, |this| {
|
||||
this.rotate(percentage(270. / 360.))
|
||||
}),
|
||||
)
|
||||
.child(self.label.clone())
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(cx.listener(move |view, _event, _window, cx| {
|
||||
view.is_collapsed = !view.is_collapsed;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.when(!self.is_collapsed, |this| {
|
||||
this.child(self.render_item(window, cx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,32 @@
|
||||
use crate::views::sidebar::inbox::Inbox;
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
use compose::Compose;
|
||||
use gpui::{
|
||||
div, px, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window,
|
||||
div, img, percentage, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext,
|
||||
Context, Div, Empty, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, SharedString, Stateful, StatefulInteractiveElement, Styled,
|
||||
Window,
|
||||
};
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
|
||||
ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
use super::app::AddPanel;
|
||||
|
||||
mod compose;
|
||||
mod inbox;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
|
||||
Sidebar::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Sidebar {
|
||||
// Panel
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
// Dock
|
||||
inbox: Entity<Inbox>,
|
||||
label: SharedString,
|
||||
is_collapsed: bool,
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
@@ -35,19 +34,19 @@ impl Sidebar {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let inbox = cx.new(|cx| Inbox::new(window, cx));
|
||||
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
let label = SharedString::from("Inbox");
|
||||
|
||||
Self {
|
||||
name: "Sidebar".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
inbox,
|
||||
is_collapsed: false,
|
||||
focus_handle,
|
||||
label,
|
||||
}
|
||||
}
|
||||
|
||||
fn show_compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn render_compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let compose = cx.new(|cx| Compose::new(window, cx));
|
||||
|
||||
window.open_modal(cx, move |modal, window, cx| {
|
||||
@@ -79,6 +78,73 @@ impl Sidebar {
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_room(&self, ix: usize, room: &Entity<Room>, cx: &Context<Self>) -> Stateful<Div> {
|
||||
let room = room.read(cx);
|
||||
|
||||
div()
|
||||
.id(ix)
|
||||
.px_1()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.text_xs()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)))
|
||||
.child(div().flex_1().truncate().font_medium().map(|this| {
|
||||
if room.is_group() {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().accent.step(cx, ColorScaleStep::THREE))
|
||||
.child(Icon::new(IconName::GroupFill).size_3().text_color(
|
||||
cx.theme().accent.step(cx, ColorScaleStep::TWELVE),
|
||||
)),
|
||||
)
|
||||
.when_some(room.name(), |this, name| this.child(name))
|
||||
} else {
|
||||
this.when_some(room.first_member(), |this, member| {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(img(member.avatar()).size_6().rounded_full().flex_shrink_0())
|
||||
.child(member.name())
|
||||
})
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(room.ago()),
|
||||
)
|
||||
.on_click({
|
||||
let id = room.id;
|
||||
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.open(id, window, cx);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn open(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.dispatch_action(
|
||||
Box::new(AddPanel::new(
|
||||
super::app::PanelKind::Room(id),
|
||||
ui::dock_area::dock::DockPlacement::Center,
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Sidebar {
|
||||
@@ -90,14 +156,6 @@ impl Panel for Sidebar {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
@@ -117,41 +175,116 @@ impl Focusable for Sidebar {
|
||||
|
||||
impl Render for Sidebar {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.py_3()
|
||||
.gap_3()
|
||||
let entity = cx.entity();
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex().px_2().gap_1().child(
|
||||
div()
|
||||
.id("new")
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_1()
|
||||
.h_7()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.size_6()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
|
||||
.child(
|
||||
Icon::new(IconName::ComposeFill)
|
||||
.small()
|
||||
.text_color(cx.theme().base.darken(cx)),
|
||||
),
|
||||
)
|
||||
.child("New Message")
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(cx.listener(|this, _, window, cx| this.show_compose(window, cx))),
|
||||
),
|
||||
div()
|
||||
.px_2()
|
||||
.py_3()
|
||||
.w_full()
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id("new_message")
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_1()
|
||||
.h_7()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.size_6()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
|
||||
.child(
|
||||
Icon::new(IconName::ComposeFill)
|
||||
.small()
|
||||
.text_color(cx.theme().base.darken(cx)),
|
||||
),
|
||||
)
|
||||
.child("New Message")
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
// Open compose modal
|
||||
this.render_compose(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(Empty),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id("inbox_header")
|
||||
.px_1()
|
||||
.h_7()
|
||||
.flex()
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.size_6()
|
||||
.when(self.is_collapsed, |this| {
|
||||
this.rotate(percentage(270. / 360.))
|
||||
}),
|
||||
)
|
||||
.child(self.label.clone())
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(cx.listener(move |view, _event, _window, cx| {
|
||||
view.is_collapsed = !view.is_collapsed;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.when(!self.is_collapsed, |this| {
|
||||
this.flex_1()
|
||||
.w_full()
|
||||
.when_some(ChatRegistry::global(cx), |this, state| {
|
||||
let rooms = state.read(cx).rooms();
|
||||
let len = rooms.len();
|
||||
|
||||
this.child(
|
||||
uniform_list(
|
||||
entity,
|
||||
"rooms",
|
||||
len,
|
||||
move |this, range, _, cx| {
|
||||
let mut items = vec![];
|
||||
|
||||
for ix in range {
|
||||
if let Some(room) = rooms.get(ix) {
|
||||
items.push(this.render_room(ix, room, cx));
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
},
|
||||
)
|
||||
.size_full(),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(self.inbox.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
use gpui::{
|
||||
div, svg, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Window,
|
||||
};
|
||||
use ui::theme::{scale::ColorScaleStep, ActiveTheme};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Startup> {
|
||||
Startup::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Startup {}
|
||||
|
||||
impl Startup {
|
||||
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|_| Self {})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Startup {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,6 @@ nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
chrono.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
oneshot.workspace = true
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
use crate::room::Room;
|
||||
use anyhow::anyhow;
|
||||
use common::utils::{compare, room_hash, signer_public_key};
|
||||
use common::{last_seen::LastSeen, utils::room_hash};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, WeakEntity};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::get_client;
|
||||
use std::cmp::Reverse;
|
||||
|
||||
use crate::room::Room;
|
||||
use std::{cmp::Reverse, rc::Rc, sync::RwLock};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ChatRegistry::register(cx);
|
||||
@@ -17,7 +16,7 @@ struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||
impl Global for GlobalChatRegistry {}
|
||||
|
||||
pub struct ChatRegistry {
|
||||
rooms: Vec<Entity<Room>>,
|
||||
rooms: Rc<RwLock<Vec<Entity<Room>>>>,
|
||||
is_loading: bool,
|
||||
}
|
||||
|
||||
@@ -40,83 +39,67 @@ impl ChatRegistry {
|
||||
// Set global state
|
||||
cx.set_global(GlobalChatRegistry(entity.clone()));
|
||||
|
||||
// Observe and load metadata for any new rooms
|
||||
cx.observe_new::<Room>(|this, _window, cx| {
|
||||
let client = get_client();
|
||||
let pubkeys = this.pubkeys();
|
||||
let (tx, rx) = oneshot::channel::<Vec<(PublicKey, Metadata)>>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
for public_key in pubkeys.into_iter() {
|
||||
if let Ok(metadata) = client.database().metadata(public_key).await {
|
||||
profiles.push((public_key, metadata.unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
|
||||
_ = tx.send(profiles);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if let Ok(profiles) = rx.await {
|
||||
if let Some(room) = this.upgrade() {
|
||||
_ = cx.update_entity(&room, |this, cx| {
|
||||
for profile in profiles.into_iter() {
|
||||
this.set_metadata(profile.0, profile.1);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.detach();
|
||||
|
||||
entity
|
||||
})
|
||||
}
|
||||
|
||||
fn new(_cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
rooms: vec![],
|
||||
rooms: Rc::new(RwLock::new(vec![])),
|
||||
is_loading: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_rooms_ids(&self, cx: &mut Context<Self>) -> Vec<u64> {
|
||||
self.rooms.iter().map(|room| room.read(cx).id).collect()
|
||||
self.rooms
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|room| room.read(cx).id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn load_chat_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Vec<Event>>();
|
||||
let (tx, rx) = oneshot::channel::<Option<Vec<Event>>>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(public_key) = signer_public_key(client).await {
|
||||
let filter = Filter::new()
|
||||
let result = async {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let send = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.author(public_key);
|
||||
|
||||
// Get all DM events from database
|
||||
if let Ok(events) = client.database().query(filter).await {
|
||||
let result: Vec<Event> = events
|
||||
.into_iter()
|
||||
.filter(|ev| ev.tags.public_keys().peekable().peek().is_some())
|
||||
.unique_by(room_hash)
|
||||
.sorted_by_key(|ev| Reverse(ev.created_at))
|
||||
.collect();
|
||||
let recv = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.pubkey(public_key);
|
||||
|
||||
_ = tx.send(result);
|
||||
}
|
||||
let send_events = client.database().query(send).await?;
|
||||
let recv_events = client.database().query(recv).await?;
|
||||
|
||||
Ok::<_, anyhow::Error>(send_events.merge(recv_events))
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Ok(events) = result {
|
||||
let result: Vec<Event> = events
|
||||
.into_iter()
|
||||
.filter(|ev| ev.tags.public_keys().peekable().peek().is_some())
|
||||
.unique_by(room_hash)
|
||||
.sorted_by_key(|ev| Reverse(ev.created_at))
|
||||
.collect();
|
||||
|
||||
_ = tx.send(Some(result));
|
||||
} else {
|
||||
_ = tx.send(None);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(events) = rx.await {
|
||||
if let Ok(Some(events)) = rx.await {
|
||||
if !events.is_empty() {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
@@ -127,14 +110,14 @@ impl ChatRegistry {
|
||||
let new = room_hash(&ev);
|
||||
// Filter all seen events
|
||||
if !current_rooms.iter().any(|this| this == &new) {
|
||||
Some(cx.new(|cx| Room::parse(&ev, cx)))
|
||||
Some(Room::new(&ev, cx))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
this.rooms.extend(items);
|
||||
this.rooms.write().unwrap().extend(items);
|
||||
this.is_loading = false;
|
||||
|
||||
cx.notify();
|
||||
@@ -146,8 +129,8 @@ impl ChatRegistry {
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn rooms(&self) -> &Vec<Entity<Room>> {
|
||||
&self.rooms
|
||||
pub fn rooms(&self) -> Vec<Entity<Room>> {
|
||||
self.rooms.read().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn is_loading(&self) -> bool {
|
||||
@@ -156,18 +139,25 @@ impl ChatRegistry {
|
||||
|
||||
pub fn get(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
|
||||
self.rooms
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|model| model.read(cx).id == *id)
|
||||
.map(|room| room.downgrade())
|
||||
}
|
||||
|
||||
pub fn new_room(&mut self, room: Room, cx: &mut Context<Self>) -> Result<(), anyhow::Error> {
|
||||
if !self
|
||||
.rooms
|
||||
pub fn push_room(
|
||||
&mut self,
|
||||
room: Entity<Room>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let mut rooms = self.rooms.write().unwrap();
|
||||
|
||||
if !rooms
|
||||
.iter()
|
||||
.any(|current| compare(¤t.read(cx).pubkeys(), &room.pubkeys()))
|
||||
.any(|current| current.read(cx) == room.read(cx))
|
||||
{
|
||||
self.rooms.insert(0, cx.new(|_| room));
|
||||
rooms.insert(0, room);
|
||||
cx.notify();
|
||||
|
||||
Ok(())
|
||||
@@ -177,32 +167,27 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
pub fn push_message(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
// Get all pubkeys from event's tags for comparision
|
||||
let mut pubkeys: Vec<_> = event.tags.public_keys().copied().collect();
|
||||
pubkeys.push(event.pubkey);
|
||||
let id = room_hash(&event);
|
||||
let mut rooms = self.rooms.write().unwrap();
|
||||
|
||||
if let Some(room) = self
|
||||
.rooms
|
||||
.iter()
|
||||
.find(|room| compare(&room.read(cx).pubkeys(), &pubkeys))
|
||||
{
|
||||
if let Some(room) = rooms.iter().find(|room| room.read(cx).id == id) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.last_seen.set(event.created_at);
|
||||
this.new_messages.update(cx, |this, cx| {
|
||||
this.push(event);
|
||||
cx.notify();
|
||||
});
|
||||
if let Some(last_seen) = Rc::get_mut(&mut this.last_seen) {
|
||||
*last_seen = LastSeen(event.created_at);
|
||||
}
|
||||
this.new_messages.push(event);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Re sort rooms by last seen
|
||||
self.rooms
|
||||
.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
|
||||
rooms.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
|
||||
|
||||
cx.notify();
|
||||
} else {
|
||||
let room = cx.new(|cx| Room::parse(&event, cx));
|
||||
self.rooms.insert(0, room);
|
||||
let mut rooms = self.rooms.write().unwrap();
|
||||
let new_room = Room::new(&event, cx);
|
||||
|
||||
rooms.insert(0, new_room);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,138 +1,161 @@
|
||||
use common::{
|
||||
last_seen::LastSeen,
|
||||
profile::NostrProfile,
|
||||
utils::{compare, random_name, room_hash},
|
||||
utils::{random_name, room_hash},
|
||||
};
|
||||
use gpui::{App, AppContext, Entity, SharedString};
|
||||
use nostr_sdk::prelude::*;
|
||||
use std::collections::HashSet;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::get_client;
|
||||
use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
pub struct Room {
|
||||
pub id: u64,
|
||||
pub title: Option<SharedString>,
|
||||
pub owner: NostrProfile, // Owner always match current user
|
||||
pub members: Vec<NostrProfile>, // Extract from event's tags
|
||||
pub last_seen: LastSeen,
|
||||
pub is_group: bool,
|
||||
pub new_messages: Entity<Vec<Event>>, // Hold all new messages
|
||||
pub last_seen: Rc<LastSeen>,
|
||||
/// Subject of the room (Nostr)
|
||||
pub title: String,
|
||||
/// Display name of the room (used for display purposes in Coop)
|
||||
pub display_name: Option<SharedString>,
|
||||
/// All members of the room
|
||||
pub members: SmallVec<[NostrProfile; 2]>,
|
||||
/// Store all new messages
|
||||
pub new_messages: Vec<Event>,
|
||||
}
|
||||
|
||||
impl PartialEq for Room {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
compare(&self.pubkeys(), &other.pubkeys())
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Room {
|
||||
pub fn new(
|
||||
id: u64,
|
||||
owner: NostrProfile,
|
||||
members: Vec<NostrProfile>,
|
||||
title: Option<SharedString>,
|
||||
last_seen: LastSeen,
|
||||
cx: &mut App,
|
||||
) -> Self {
|
||||
let new_messages = cx.new(|_| Vec::new());
|
||||
let is_group = members.len() > 1;
|
||||
let title = if title.is_none() {
|
||||
Some(random_name(2).into())
|
||||
} else {
|
||||
title
|
||||
};
|
||||
|
||||
Self {
|
||||
id,
|
||||
owner,
|
||||
members,
|
||||
title,
|
||||
last_seen,
|
||||
is_group,
|
||||
new_messages,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert nostr event to room
|
||||
pub fn parse(event: &Event, cx: &mut App) -> Room {
|
||||
pub fn new(event: &Event, cx: &mut App) -> Entity<Self> {
|
||||
let id = room_hash(event);
|
||||
let last_seen = LastSeen(event.created_at);
|
||||
|
||||
// Always equal to current user
|
||||
let owner = NostrProfile::new(event.pubkey, Metadata::default());
|
||||
|
||||
// Get all pubkeys that invole in this group
|
||||
let members: Vec<NostrProfile> = event
|
||||
.tags
|
||||
.public_keys()
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.map(|public_key| NostrProfile::new(*public_key, Metadata::default()))
|
||||
.collect();
|
||||
|
||||
// Get title from event's tags
|
||||
let last_seen = Rc::new(LastSeen(event.created_at));
|
||||
// Get the subject from the event's tags, or create a random subject if none is found
|
||||
let title = if let Some(tag) = event.tags.find(TagKind::Subject) {
|
||||
tag.content().map(|s| s.to_owned().into())
|
||||
tag.content()
|
||||
.map(|s| s.to_owned())
|
||||
.unwrap_or(random_name(2))
|
||||
} else {
|
||||
None
|
||||
random_name(2)
|
||||
};
|
||||
|
||||
Self::new(id, owner, members, title, last_seen, cx)
|
||||
let room = cx.new(|cx| {
|
||||
let this = Self {
|
||||
id,
|
||||
last_seen,
|
||||
title,
|
||||
display_name: None,
|
||||
members: smallvec![],
|
||||
new_messages: vec![],
|
||||
};
|
||||
|
||||
let mut pubkeys = vec![];
|
||||
|
||||
// Get all pubkeys from event's tags
|
||||
pubkeys.extend(event.tags.public_keys().collect::<HashSet<_>>());
|
||||
pubkeys.push(event.pubkey);
|
||||
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Vec<NostrProfile>>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await.unwrap();
|
||||
let signer_pubkey = signer.get_public_key().await.unwrap();
|
||||
let mut profiles = vec![];
|
||||
|
||||
for public_key in pubkeys.into_iter() {
|
||||
if let Ok(result) = client.database().metadata(public_key).await {
|
||||
let metadata = result.unwrap_or_default();
|
||||
let profile = NostrProfile::new(public_key, metadata);
|
||||
|
||||
if public_key == signer_pubkey {
|
||||
profiles.push(profile);
|
||||
} else {
|
||||
profiles.insert(0, profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = tx.send(profiles);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(profiles) = rx.await {
|
||||
_ = cx.update(|cx| {
|
||||
let display_name = if profiles.len() > 2 {
|
||||
let merged = profiles
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|profile| profile.name().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
let name: SharedString =
|
||||
format!("{}, +{}", merged, profiles.len() - 2).into();
|
||||
|
||||
Some(name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
_ = this.update(cx, |this: &mut Room, cx| {
|
||||
this.members.extend(profiles);
|
||||
this.display_name = display_name;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
this
|
||||
});
|
||||
|
||||
room
|
||||
}
|
||||
|
||||
/// Set contact's metadata by public key
|
||||
pub fn set_metadata(&mut self, public_key: PublicKey, metadata: Metadata) {
|
||||
if self.owner.public_key() == public_key {
|
||||
self.owner.set_metadata(&metadata);
|
||||
}
|
||||
|
||||
for member in self.members.iter_mut() {
|
||||
if member.public_key() == public_key {
|
||||
member.set_metadata(&metadata);
|
||||
}
|
||||
}
|
||||
pub fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Get room's member by public key
|
||||
pub fn member(&self, public_key: &PublicKey) -> Option<NostrProfile> {
|
||||
if &self.owner.public_key() == public_key {
|
||||
Some(self.owner.clone())
|
||||
} else {
|
||||
self.members
|
||||
.iter()
|
||||
.find(|m| &m.public_key() == public_key)
|
||||
.cloned()
|
||||
}
|
||||
self.members
|
||||
.iter()
|
||||
.find(|m| &m.public_key() == public_key)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Get room's first member's public key
|
||||
pub fn first_member(&self) -> Option<&NostrProfile> {
|
||||
self.members.first()
|
||||
}
|
||||
|
||||
/// Collect room's member's public keys
|
||||
pub fn public_keys(&self) -> Vec<PublicKey> {
|
||||
self.members.iter().map(|m| m.public_key()).collect()
|
||||
}
|
||||
|
||||
/// Get room's display name
|
||||
pub fn name(&self) -> String {
|
||||
if self.members.len() <= 2 {
|
||||
self.members
|
||||
.iter()
|
||||
.map(|profile| profile.name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
} else {
|
||||
let name = self
|
||||
.members
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|profile| profile.name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
format!("{}, +{}", name, self.members.len() - 2)
|
||||
}
|
||||
pub fn name(&self) -> Option<SharedString> {
|
||||
self.display_name.clone()
|
||||
}
|
||||
|
||||
pub fn last_seen(&self) -> &LastSeen {
|
||||
&self.last_seen
|
||||
/// Determine if room is a group
|
||||
pub fn is_group(&self) -> bool {
|
||||
self.members.len() > 2
|
||||
}
|
||||
|
||||
/// Get all public keys from current room
|
||||
pub fn pubkeys(&self) -> Vec<PublicKey> {
|
||||
let mut pubkeys: Vec<_> = self.members.iter().map(|m| m.public_key()).collect();
|
||||
pubkeys.push(self.owner.public_key());
|
||||
/// Get room's last seen
|
||||
pub fn last_seen(&self) -> Rc<LastSeen> {
|
||||
self.last_seen.clone()
|
||||
}
|
||||
|
||||
pubkeys
|
||||
/// Get room's last seen as ago format
|
||||
pub fn ago(&self) -> SharedString {
|
||||
self.last_seen.ago()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
use chrono::{Datelike, Local, TimeZone};
|
||||
use chrono::{Local, TimeZone};
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
const NOW: &str = "now";
|
||||
const SECONDS_IN_MINUTE: i64 = 60;
|
||||
const MINUTES_IN_HOUR: i64 = 60;
|
||||
const HOURS_IN_DAY: i64 = 24;
|
||||
const DAYS_IN_MONTH: i64 = 30;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct LastSeen(pub Timestamp);
|
||||
|
||||
impl LastSeen {
|
||||
pub fn ago(&self) -> SharedString {
|
||||
let now = Local::now();
|
||||
let input_time = Local.timestamp_opt(self.0.as_u64() as i64, 0).unwrap();
|
||||
let diff = (now - input_time).num_hours();
|
||||
let input_time = match Local.timestamp_opt(self.0.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "Invalid timestamp".into(),
|
||||
};
|
||||
let duration = now.signed_duration_since(input_time);
|
||||
|
||||
if diff < 24 {
|
||||
let duration = now.signed_duration_since(input_time);
|
||||
|
||||
if duration.num_seconds() < 60 {
|
||||
"now".to_string().into()
|
||||
} else if duration.num_minutes() == 1 {
|
||||
"1m".to_string().into()
|
||||
} else if duration.num_minutes() < 60 {
|
||||
format!("{}m", duration.num_minutes()).into()
|
||||
} else if duration.num_hours() == 1 {
|
||||
"1h".to_string().into()
|
||||
} else if duration.num_hours() < 24 {
|
||||
format!("{}h", duration.num_hours()).into()
|
||||
} else if duration.num_days() == 1 {
|
||||
"1d".to_string().into()
|
||||
} else {
|
||||
format!("{}d", duration.num_days()).into()
|
||||
}
|
||||
} else {
|
||||
input_time.format("%b %d").to_string().into()
|
||||
match duration {
|
||||
d if d.num_seconds() < SECONDS_IN_MINUTE => NOW.into(),
|
||||
d if d.num_minutes() < MINUTES_IN_HOUR => format!("{}m", d.num_minutes()),
|
||||
d if d.num_hours() < HOURS_IN_DAY => format!("{}h", d.num_hours()),
|
||||
d if d.num_days() < DAYS_IN_MONTH => format!("{}d", d.num_days()),
|
||||
_ => input_time.format("%b %d").to_string(),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn human_readable(&self) -> SharedString {
|
||||
let now = Local::now();
|
||||
let input_time = Local.timestamp_opt(self.0.as_u64() as i64, 0).unwrap();
|
||||
let input_time = match Local.timestamp_opt(self.0.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "Invalid timestamp".into(),
|
||||
};
|
||||
|
||||
if input_time.day() == now.day() {
|
||||
format!("Today at {}", input_time.format("%H:%M %p")).into()
|
||||
} else if input_time.day() == now.day() - 1 {
|
||||
format!("Yesterday at {}", input_time.format("%H:%M %p")).into()
|
||||
} else {
|
||||
format!(
|
||||
"{}, {}",
|
||||
input_time.format("%d/%m/%y"),
|
||||
input_time.format("%H:%M %p")
|
||||
)
|
||||
.into()
|
||||
let input_date = input_time.date_naive();
|
||||
let now_date = now.date_naive();
|
||||
let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
|
||||
|
||||
let time_format = input_time.format("%H:%M %p");
|
||||
|
||||
match input_date {
|
||||
date if date == now_date => format!("Today at {time_format}"),
|
||||
date if date == yesterday_date => format!("Yesterday at {time_format}"),
|
||||
_ => format!("{}, {time_format}", input_time.format("%d/%m/%y")),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn set(&mut self, created_at: Timestamp) {
|
||||
|
||||
@@ -1,37 +1,24 @@
|
||||
use crate::constants::IMAGE_SERVICE;
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
use crate::constants::IMAGE_SERVICE;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NostrProfile {
|
||||
public_key: PublicKey,
|
||||
metadata: Metadata,
|
||||
}
|
||||
|
||||
impl AsRef<PublicKey> for NostrProfile {
|
||||
fn as_ref(&self) -> &PublicKey {
|
||||
&self.public_key
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Metadata> for NostrProfile {
|
||||
fn as_ref(&self) -> &Metadata {
|
||||
&self.metadata
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for NostrProfile {}
|
||||
|
||||
impl PartialEq for NostrProfile {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.public_key() == other.public_key()
|
||||
}
|
||||
avatar: SharedString,
|
||||
name: SharedString,
|
||||
}
|
||||
|
||||
impl NostrProfile {
|
||||
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
||||
let name = Self::extract_name(&public_key, &metadata);
|
||||
let avatar = Self::extract_avatar(&metadata);
|
||||
|
||||
Self {
|
||||
public_key,
|
||||
metadata,
|
||||
name,
|
||||
avatar,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,47 +27,44 @@ impl NostrProfile {
|
||||
self.public_key
|
||||
}
|
||||
|
||||
/// Get contact's avatar
|
||||
pub fn avatar(&self) -> String {
|
||||
if let Some(picture) = &self.metadata.picture {
|
||||
if picture.len() > 1 {
|
||||
pub fn avatar(&self) -> SharedString {
|
||||
self.avatar.clone()
|
||||
}
|
||||
|
||||
pub fn name(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn extract_avatar(metadata: &Metadata) -> SharedString {
|
||||
metadata
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| {
|
||||
format!(
|
||||
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
|
||||
IMAGE_SERVICE, picture
|
||||
)
|
||||
} else {
|
||||
"brand/avatar.png".into()
|
||||
}
|
||||
} else {
|
||||
"brand/avatar.png".into()
|
||||
}
|
||||
.into()
|
||||
})
|
||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||
}
|
||||
|
||||
/// Get contact's name, fallback to public key as shorted format
|
||||
pub fn name(&self) -> String {
|
||||
if let Some(display_name) = &self.metadata.display_name {
|
||||
fn extract_name(public_key: &PublicKey, metadata: &Metadata) -> SharedString {
|
||||
if let Some(display_name) = metadata.display_name.as_ref() {
|
||||
if !display_name.is_empty() {
|
||||
return display_name.to_owned();
|
||||
return display_name.into();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = &self.metadata.name {
|
||||
if let Some(name) = metadata.name.as_ref() {
|
||||
if !name.is_empty() {
|
||||
return name.to_owned();
|
||||
return name.into();
|
||||
}
|
||||
}
|
||||
|
||||
let pubkey = self.public_key.to_string();
|
||||
format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..])
|
||||
}
|
||||
let pubkey = public_key.to_hex();
|
||||
|
||||
/// Get contact's metadata
|
||||
pub fn metadata(&mut self) -> &Metadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
/// Set contact's metadata
|
||||
pub fn set_metadata(&mut self, metadata: &Metadata) {
|
||||
self.metadata = metadata.clone()
|
||||
format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..]).into()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,12 @@
|
||||
use crate::constants::{ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID, NIP96_SERVER};
|
||||
use crate::constants::NIP96_SERVER;
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use rnglib::{Language, RNG};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
pub async fn signer_public_key(client: &Client) -> anyhow::Result<PublicKey, anyhow::Error> {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
Ok(public_key)
|
||||
}
|
||||
|
||||
pub async fn preload(client: &Client, public_key: PublicKey) -> anyhow::Result<(), anyhow::Error> {
|
||||
let sync_opts = SyncOptions::default();
|
||||
let subscription = Filter::new()
|
||||
.kind(Kind::ContactList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Get contact list
|
||||
_ = client.sync(subscription, &sync_opts).await;
|
||||
|
||||
let all_messages_sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
|
||||
let new_message_sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||
|
||||
// Create a filter for getting all gift wrapped events send to current user
|
||||
let all_messages = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
|
||||
// Create a filter for getting new message
|
||||
let new_message = Filter::new()
|
||||
.kind(Kind::GiftWrap)
|
||||
.pubkey(public_key)
|
||||
.limit(0);
|
||||
|
||||
// Subscribe for all messages
|
||||
_ = client
|
||||
.subscribe_with_id(
|
||||
all_messages_sub_id,
|
||||
all_messages,
|
||||
Some(
|
||||
SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::WaitDurationAfterEOSE(Duration::from_secs(3))),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Subscribe for new message
|
||||
_ = client
|
||||
.subscribe_with_id(new_message_sub_id, new_message, None)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn nip96_upload(client: &Client, file: Vec<u8>) -> anyhow::Result<Url, anyhow::Error> {
|
||||
let signer = client.signer().await?;
|
||||
let server_url = Url::parse(NIP96_SERVER)?;
|
||||
@@ -68,10 +18,28 @@ pub async fn nip96_upload(client: &Client, file: Vec<u8>) -> anyhow::Result<Url,
|
||||
}
|
||||
|
||||
pub fn room_hash(event: &Event) -> u64 {
|
||||
let pubkeys: Vec<&PublicKey> = event.tags.public_keys().unique().collect();
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<&PublicKey> = vec![];
|
||||
|
||||
// Add all public keys from event
|
||||
pubkeys.push(&event.pubkey);
|
||||
pubkeys.extend(
|
||||
event
|
||||
.tags
|
||||
.public_keys()
|
||||
.unique()
|
||||
.sorted()
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
// Generate unique hash
|
||||
pubkeys.hash(&mut hasher);
|
||||
pubkeys
|
||||
.into_iter()
|
||||
.unique()
|
||||
.sorted()
|
||||
.collect::<Vec<_>>()
|
||||
.hash(&mut hasher);
|
||||
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
|
||||
@@ -358,8 +358,6 @@ impl Render for Dock {
|
||||
return div();
|
||||
}
|
||||
|
||||
let cache_style = gpui::StyleRefinement::default().v_flex().size_full();
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
@@ -375,7 +373,7 @@ impl Render for Dock {
|
||||
.map(|this| match &self.panel {
|
||||
DockItem::Split { view, .. } => this.child(view.clone()),
|
||||
DockItem::Tabs { view, .. } => this.child(view.clone()),
|
||||
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)),
|
||||
DockItem::Panel { view, .. } => this.child(view.clone().view()),
|
||||
})
|
||||
.child(self.render_resize_handle(window, cx))
|
||||
.child(DockElement {
|
||||
|
||||
Reference in New Issue
Block a user