18 Commits

Author SHA1 Message Date
3f8c02aef8 chore: bump version 2025-02-23 14:14:20 +07:00
b73babf274 feat: add new default avatar 2025-02-23 14:13:56 +07:00
reya
bbc778d5ca 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
2025-02-23 08:29:05 +07:00
cfa628a8a6 feat: automatically load inbox on startup 2025-02-19 15:35:14 +07:00
5e1d76bbcd chore: bump version 2025-02-19 09:22:49 +07:00
61fb90bd34 chore: improve chat panel 2025-02-19 09:08:29 +07:00
50242981a5 feat: sort inbox by time after added new messages 2025-02-18 17:04:19 +07:00
85c485a4e4 feat: refactor async task and remove tokio as dep 2025-02-18 16:43:30 +07:00
48af00950a fix: cannot launch app on linux 2025-02-17 13:23:32 +07:00
31e94c53c6 chore: remove cargo-packager-updater (it sucks) 2025-02-16 20:32:23 +07:00
ae01a2d67a chore: fix version 2025-02-16 20:06:57 +07:00
2a5a3b5c0a fix: missing message 2025-02-16 15:48:22 +07:00
0c45695edb feat: verify nip65 relays before set messaging relays 2025-02-16 15:41:25 +07:00
ea5009933c feat: add support for nip05 in compose modal 2025-02-16 13:43:49 +07:00
0feb69b72e chore: update deps 2025-02-15 09:01:55 +07:00
ab7664c872 chore: fix versioning 2025-02-14 07:46:40 +07:00
cd6a9f0550 chore: bump version 2025-02-13 20:45:49 +07:00
ce9193c187 chore: fix crash on update relays 2025-02-13 20:44:50 +07:00
38 changed files with 2093 additions and 1883 deletions

811
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,6 @@ coop = { path = "crates/*" }
# UI
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr
@@ -16,11 +15,15 @@ nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
"lmdb",
"all-nips",
"nip96",
"nip59",
"nip49",
"nip44",
"nip05",
] }
smol = "2"
tokio = { version = "1", features = ["full"] }
oneshot = "0.1.10"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dirs = "5.0"
@@ -29,8 +32,9 @@ futures = "0.3.30"
chrono = "0.4.38"
tracing = "0.1.40"
anyhow = "1.0.44"
smallvec = "1.13.2"
smallvec = "1.14.0"
rust-embed = "8.5.0"
log = "0.4"
[profile.release]
strip = true

View File

@@ -2,7 +2,7 @@ name = "coop"
description = "Coop is a cross-platform Nostr client designed for secure communication focus on simplicity and customizability."
product-name = "Coop"
identifier = "su.reya.coop"
version = "0.1.0"
version = "0.1.3"
resources = ["assets/*/*", "Cargo.toml", "./LICENSE", "./README.md"]
icons = [
"assets/brand/32x32.png",

BIN
assets/brand/avatar.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

16
crates/account/Cargo.toml Normal file
View 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

View File

@@ -0,0 +1 @@
pub mod registry;

View 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)
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "coop"
version = "0.1.0"
version = "0.1.3"
edition = "2021"
publish = false
@@ -13,12 +13,11 @@ ui = { path = "../ui" }
common = { path = "../common" }
state = { path = "../state" }
chats = { path = "../chats" }
account = { path = "../account" }
gpui.workspace = true
gpui_tokio.workspace = true
reqwest_client.workspace = true
tokio.workspace = true
nostr-connect.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
@@ -27,8 +26,10 @@ serde_json.workspace = true
itertools.workspace = true
dirs.workspace = true
rust-embed.workspace = true
log.workspace = true
smol.workspace = true
oneshot.workspace = true
cargo-packager-updater = "0.2.2"
rustls = "0.23.23"
futures= "0.3"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
log = "0.4"

View File

@@ -1,12 +1,9 @@
use asset::Assets;
use async_utility::task::spawn;
use chats::registry::ChatRegistry;
use common::{
constants::{
ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, FAKE_SIG, 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::{
actions, px, size, App, AppContext, Application, AsyncApp, Bounds, KeyBinding, Menu, MenuItem,
WindowBounds, WindowKind, WindowOptions,
@@ -16,20 +13,24 @@ use gpui::{point, SharedString, TitlebarOptions};
#[cfg(target_os = "linux")]
use gpui::{WindowBackgroundAppearance, WindowDecorations};
use log::{error, info};
use nostr_sdk::prelude::*;
use state::{get_client, initialize_client};
use std::{borrow::Cow, collections::HashSet, str::FromStr, sync::Arc, time::Duration};
use tokio::sync::{mpsc, oneshot};
use nostr_sdk::SubscriptionId;
use nostr_sdk::{
pool::prelude::ReqExitPolicy, Client, Event, Filter, Keys, Kind, PublicKey, RelayMessage,
RelayPoolNotification, SubscribeAutoCloseOptions,
};
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;
actions!(main_menu, [Quit]);
actions!(coop, [Quit]);
#[derive(Clone)]
pub enum Signal {
enum Signal {
/// Receive event
Event(Event),
/// Receive EOSE
@@ -37,211 +38,154 @@ pub enum Signal {
}
fn main() {
// Initialize Nostr client
initialize_client();
// Fix crash on startup
// TODO: why this is needed?
_ = rustls::crypto::ring::default_provider().install_default();
// Enable logging
tracing_subscriber::fmt::init();
// Get client
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(1024);
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(100);
// Initialize nostr client
let client = get_client();
let (signal_tx, mut signal_rx) = tokio::sync::mpsc::channel::<Signal>(2048);
spawn(async move {
// Add some bootstrap relays
_ = client.add_relay("wss://relay.damus.io/").await;
_ = client.add_relay("wss://relay.primal.net/").await;
_ = client.add_relay("wss://user.kindpag.es/").await;
_ = client.add_relay("wss://directory.yabu.me/").await;
_ = client.add_discovery_relay("wss://relaydiscovery.com").await;
// Connect to all relays
_ = client.connect().await
});
spawn(async move {
let (batch_tx, mut batch_rx) = mpsc::channel::<Cow<Event>>(20);
async fn sync_metadata(client: &Client, buffer: &HashSet<PublicKey>) {
let filter = Filter::new()
.authors(buffer.iter().copied())
.kind(Kind::Metadata)
.limit(buffer.len());
if let Err(e) = client.sync(filter, &SyncOptions::default()).await {
error!("NEG error: {e}");
}
}
async fn process_batch(client: &Client, events: &[Cow<'_, Event>]) {
let sig = Signature::from_str(FAKE_SIG).unwrap();
let mut buffer: HashSet<PublicKey> = HashSet::with_capacity(20);
for event in events.iter() {
if let Ok(UnwrappedGift { mut rumor, sender }) =
client.unwrap_gift_wrap(event).await
{
let pubkeys: HashSet<PublicKey> = event.tags.public_keys().copied().collect();
buffer.extend(pubkeys);
buffer.insert(sender);
// Create event's ID is not exist
rumor.ensure_id();
// Save event to database
if let Some(id) = rumor.id {
let ev = Event::new(
id,
rumor.pubkey,
rumor.created_at,
rumor.kind,
rumor.tags,
rumor.content,
sig,
);
if let Err(e) = client.database().save_event(&ev).await {
error!("Save error: {}", e);
}
}
}
}
sync_metadata(client, &buffer).await;
}
// Spawn a thread to handle batch process
spawn(async move {
const BATCH_SIZE: usize = 20;
const BATCH_TIMEOUT: Duration = Duration::from_millis(200);
let mut batch = Vec::with_capacity(20);
let mut timeout = Box::pin(tokio::time::sleep(BATCH_TIMEOUT));
loop {
tokio::select! {
event = batch_rx.recv() => {
if let Some(event) = event {
batch.push(event);
if batch.len() == BATCH_SIZE {
process_batch(client, &batch).await;
batch.clear();
timeout = Box::pin(tokio::time::sleep(BATCH_TIMEOUT));
}
} else {
break;
}
}
_ = &mut timeout => {
if !batch.is_empty() {
process_batch(client, &batch).await;
batch.clear();
}
timeout = Box::pin(tokio::time::sleep(BATCH_TIMEOUT));
}
}
}
});
let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
let sig = Signature::from_str(FAKE_SIG).unwrap();
let mut notifications = client.notifications();
while let Ok(notification) = notifications.recv().await {
if let RelayPoolNotification::Message { message, .. } = notification {
match message {
RelayMessage::Event {
event,
subscription_id,
} => match event.kind {
Kind::GiftWrap => {
if new_id == *subscription_id {
if let Ok(UnwrappedGift { mut rumor, .. }) =
client.unwrap_gift_wrap(&event).await
{
// Compute event id if not exist
rumor.ensure_id();
if let Some(id) = rumor.id {
let ev = Event::new(
id,
rumor.pubkey,
rumor.created_at,
rumor.kind,
rumor.tags,
rumor.content,
sig,
);
// Save rumor to database to further query
if let Err(e) = client.database().save_event(&ev).await {
error!("Save error: {}", e);
}
// Send new event to GPUI
if let Err(e) = signal_tx.send(Signal::Event(ev)).await {
error!("Send error: {}", e)
}
}
}
}
if let Err(e) = batch_tx.send(event).await {
error!("Failed to add to batch: {}", e);
}
}
Kind::ContactList => {
let public_keys: HashSet<_> =
event.tags.public_keys().copied().collect();
sync_metadata(client, &public_keys).await;
}
_ => {}
},
RelayMessage::EndOfStoredEvents(subscription_id) => {
if all_id == *subscription_id {
if let Err(e) = signal_tx.send(Signal::Eose).await {
error!("Failed to send eose: {}", e)
};
}
}
_ => {}
}
}
}
});
// Initialize application
let app = Application::new()
.with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
// Connect to default relays
app.background_executor()
.spawn(async {
_ = client.add_relay("wss://relay.damus.io/").await;
_ = client.add_relay("wss://relay.primal.net/").await;
_ = client.add_relay("wss://user.kindpag.es/").await;
_ = client.add_relay("wss://purplepag.es/").await;
_ = client.add_discovery_relay("wss://relaydiscovery.com").await;
_ = client.connect().await
})
.detach();
// Handle batch metadata
app.background_executor()
.spawn(async move {
const BATCH_SIZE: usize = 20;
const BATCH_TIMEOUT: Duration = Duration::from_millis(200);
let mut batch: HashSet<PublicKey> = HashSet::new();
loop {
let mut timeout = Box::pin(Timer::after(BATCH_TIMEOUT).fuse());
select! {
pubkeys = batch_rx.recv().fuse() => {
match pubkeys {
Ok(keys) => {
batch.extend(keys);
if batch.len() >= BATCH_SIZE {
sync_metadata(client, mem::take(&mut batch)).await;
}
}
Err(_) => break,
}
}
_ = timeout => {
if !batch.is_empty() {
sync_metadata(client, mem::take(&mut batch)).await;
}
}
}
}
})
.detach();
// Handle notifications
app.background_executor()
.spawn(async move {
let rng_keys = Keys::generate();
let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
let mut notifications = client.notifications();
while let Ok(notification) = notifications.recv().await {
if let RelayPoolNotification::Message { message, .. } = notification {
match message {
RelayMessage::Event {
event,
subscription_id,
} => {
match event.kind {
Kind::GiftWrap => {
if let Ok(gift) = client.unwrap_gift_wrap(&event).await {
let mut pubkeys = vec![];
// Sign the rumor with the generated keys,
// this event will be used for internal only,
// and NEVER send to relays.
if let Ok(event) = gift.rumor.sign_with_keys(&rng_keys) {
pubkeys.extend(event.tags.public_keys());
pubkeys.push(event.pubkey);
// Save the event to the database, use for query directly.
if let Err(e) =
client.database().save_event(&event).await
{
error!("Failed to save event: {}", e);
}
// Send all pubkeys to the batch
if let Err(e) = batch_tx.send(pubkeys).await {
error!("Failed to send pubkeys to batch: {}", e)
}
// Send this event to the GPUI
if new_id == *subscription_id {
if let Err(e) =
event_tx.send(Signal::Event(event)).await
{
error!("Failed to send event to GPUI: {}", e)
}
}
}
}
}
Kind::ContactList => {
let pubkeys =
event.tags.public_keys().copied().collect::<HashSet<_>>();
sync_metadata(client, pubkeys).await;
}
_ => {}
}
}
RelayMessage::EndOfStoredEvents(subscription_id) => {
if all_id == *subscription_id {
if let Err(e) = event_tx.send(Signal::Eose).await {
error!("Failed to send eose: {}", e)
};
}
}
_ => {}
}
}
}
})
.detach();
// 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 {
let is_login = client.signer().await.is_ok();
_ = tx.send(is_login);
})
.detach();
cx.spawn(|mut cx| async move {
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);
}
})
.detach();
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();
@@ -264,7 +208,26 @@ fn main() {
items: vec![MenuItem::action("Quit", Quit)],
}]);
let opts = WindowOptions {
// 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))
}
};
}
})
.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)),
@@ -284,96 +247,67 @@ fn main() {
..Default::default()
};
cx.open_window(opts, |window, cx| {
window.set_window_title(APP_NAME);
window.set_app_id(APP_ID);
window
.observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx);
})
.detach();
// Create a task to read credentials from the keyring service
let task = cx.read_credentials(KEYRING_SERVICE);
let (tx, rx) = oneshot::channel::<bool>();
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();
// 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;
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()
};
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)
});
});
Ok::<_, anyhow::Error>(true)
}
})
.detach();
.await;
// Listen for messages from the Nostr thread
cx.spawn(|cx| async move {
while let Some(signal) = signal_rx.recv().await {
match signal {
Signal::Eose => {
_ = cx.update(|cx| {
if let Some(chats) = ChatRegistry::global(cx) {
chats.update(cx, |this, cx| this.load_chat_rooms(cx))
}
});
}
Signal::Event(event) => {
_ = cx.update(|cx| {
if let Some(chats) = ChatRegistry::global(cx) {
chats.update(cx, |this, cx| this.push_message(event, cx))
}
});
}
}
}
})
.detach();
result.is_ok()
} else {
false
};
root
_ = tx.send(is_ready)
})
.expect("System error. Please re-open the app.");
.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();
});
}
async fn restore_window(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> Result<()> {
async fn sync_metadata(client: &Client, buffer: HashSet<PublicKey>) {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new()
.authors(buffer.iter().cloned())
.kind(Kind::Metadata)
.limit(buffer.len());
if let Err(e) = client.subscribe(filter, Some(opts)).await {
error!("Subscribe error: {e}");
}
}
async fn restore_window(is_login: bool, cx: &mut AsyncApp) -> anyhow::Result<()> {
let opts = cx
.update(|cx| WindowOptions {
#[cfg(not(target_os = "linux"))]
@@ -396,17 +330,19 @@ async fn restore_window(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> Res
})
.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);
#[cfg(not(target_os = "linux"))]
window
.observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx);
})
.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| {

View File

@@ -1,19 +1,13 @@
use cargo_packager_updater::{check_update, semver::Version, url::Url};
use common::{
constants::{UPDATER_PUBKEY, UPDATER_URL},
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,
StyledImage, Window,
};
use log::info;
use nostr_sdk::prelude::*;
use serde::Deserialize;
use state::get_client;
use std::sync::Arc;
use tokio::sync::oneshot;
use ui::{
button::{Button, ButtonRounded, ButtonVariants},
dock_area::{dock::DockPlacement, DockArea, DockItem},
@@ -44,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();
@@ -88,97 +83,75 @@ impl AppView {
view.set_center(center_panel, window, cx);
});
// Check and auto update to the latest version
cx.background_spawn(async move {
// Set auto updater config
let config = cargo_packager_updater::Config {
endpoints: vec![Url::parse(UPDATER_URL).expect("Failed to parse UPDATER URL")],
pubkey: String::from(UPDATER_PUBKEY),
..Default::default()
};
// Run auto updater
if let Ok(current_version) = Version::parse(env!("CARGO_PKG_VERSION")) {
if let Ok(Some(update)) = check_update(current_version, config) {
if update.download_and_install().is_ok() {
info!("Update installed")
}
}
}
})
.detach();
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));
@@ -188,7 +161,7 @@ impl AppView {
this.keyboard(false)
.closable(false)
.width(px(420.))
.title("Your Messaging Relays is not configured")
.title("Your Messaging Relays are not configured")
.child(relays.clone())
.footer(
div()
@@ -280,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",
@@ -312,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);
@@ -345,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)
@@ -379,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())

View File

@@ -1,3 +1,4 @@
use account::registry::Account;
use anyhow::anyhow;
use async_utility::task::spawn;
use chats::{registry::ChatRegistry, room::Room};
@@ -19,7 +20,6 @@ use nostr_sdk::prelude::*;
use smol::fs;
use state::get_client;
use std::sync::Arc;
use tokio::sync::oneshot;
use ui::{
button::{Button, ButtonRounded, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
@@ -40,7 +40,7 @@ pub fn init(
) -> Result<Arc<Entity<Chat>>, anyhow::Error> {
if let Some(chats) = ChatRegistry::global(cx) {
if let Some(room) = chats.read(cx).get(id, cx) {
Ok(Arc::new(Chat::new(id, &room, window, cx)))
Ok(Arc::new(Chat::new(id, room, window, cx)))
} else {
Err(anyhow!("Chat room is not exist"))
}
@@ -50,22 +50,37 @@ pub fn init(
}
#[derive(PartialEq, Eq)]
struct ChatItem {
profile: NostrProfile,
struct ParsedMessage {
avatar: SharedString,
display_name: SharedString,
created_at: SharedString,
content: SharedString,
ago: SharedString,
}
impl ParsedMessage {
pub fn new(profile: &NostrProfile, content: &str, created_at: Timestamp) -> Self {
let content = SharedString::new(content);
let created_at = LastSeen(created_at).human_readable();
Self {
avatar: profile.avatar(),
display_name: profile.name(),
created_at,
content,
}
}
}
#[derive(PartialEq, Eq)]
enum Message {
Item(Box<ChatItem>),
User(Box<ParsedMessage>),
System(SharedString),
Placeholder,
}
impl Message {
pub fn new(chat_message: ChatItem) -> Self {
Self::Item(Box::new(chat_message))
pub fn new(message: ParsedMessage) -> Self {
Self::User(Box::new(message))
}
pub fn system(content: SharedString) -> Self {
@@ -80,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: WeakEntity<Vec<Event>>,
list_state: ListState,
subscriptions: Vec<Subscription>,
// New Message
@@ -97,21 +109,22 @@ pub struct Chat {
}
impl Chat {
pub fn new(id: &u64, model: &Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Self> {
let room = model.downgrade();
let new_messages = model.read(cx).new_messages.downgrade();
pub fn new(
id: &u64,
room: WeakEntity<Room>,
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
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,
@@ -122,7 +135,9 @@ impl Chat {
},
)];
let list_state = ListState::new(0, ListAlignment::Bottom, px(1024.), {
// Initialize list state
// [item_count] always equal to 1 at the beginning
let list_state = ListState::new(1, ListAlignment::Bottom, px(1024.), {
let this = cx.entity().downgrade();
move |ix, window, cx| {
this.update(cx, |this, cx| {
@@ -133,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,
@@ -165,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();
@@ -200,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(
@@ -221,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();
@@ -279,18 +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 ago = LastSeen(Timestamp::now()).human_readable();
let message = Message::new(ChatItem {
profile: room.owner.clone(),
content: content.into(),
ago,
});
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| {
@@ -314,40 +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()
};
Some(Message::new(ChatItem {
profile: member,
content: ev.content.into(),
ago: LastSeen(ev.created_at).human_readable(),
}))
} 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| {
@@ -355,33 +357,35 @@ 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(model) = self.new_messages.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) {
let message = Message::new(ChatItem {
profile,
content: event.content.clone().into(),
ago: LastSeen(event.created_at).human_readable(),
});
let message = Message::new(ParsedMessage::new(
&profile,
&event.content,
event.created_at,
));
if !old_messages.iter().any(|old| old == &message) {
Some(message)
@@ -424,7 +428,7 @@ impl Chat {
};
// Get message
let mut content = self.input.read(cx).text().to_string();
let mut content = self.input.read(cx).text();
// Get all attaches and merge its with message
if let Some(attaches) = self.attaches.read(cx).as_ref() {
@@ -434,7 +438,7 @@ impl Chat {
.collect::<Vec<_>>()
.join("\n");
content = format!("{}\n{}", content, merged)
content = format!("{}\n{}", content, merged).into()
}
if content.is_empty() {
@@ -448,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();
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())
@@ -487,7 +495,7 @@ impl Chat {
cx.spawn(|this, mut cx| async move {
_ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this, cx| {
this.push_message(content.clone(), window, cx);
this.push_message(content.to_string(), window, cx);
});
});
@@ -597,7 +605,7 @@ impl Chat {
.w_full()
.p_2()
.map(|this| match message {
Message::Item(item) => this
Message::User(item) => this
.hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE)))
.child(
div()
@@ -612,7 +620,7 @@ impl Chat {
}),
)
.child(
img(item.profile.avatar())
img(item.avatar.clone())
.size_8()
.rounded_full()
.flex_shrink_0(),
@@ -629,8 +637,10 @@ impl Chat {
.items_baseline()
.gap_2()
.text_xs()
.child(div().font_semibold().child(item.profile.name()))
.child(div().child(item.ago.clone()).text_color(
.child(
div().font_semibold().child(item.display_name.clone()),
)
.child(div().child(item.created_at.clone()).text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)),
)
@@ -667,7 +677,7 @@ impl Chat {
.text_center()
.text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.line_height(relative(1.))
.line_height(relative(1.2))
.child(
svg()
.path("brand/coop.svg")
@@ -689,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()
@@ -713,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)
}

View File

@@ -6,7 +6,6 @@ use gpui::{
};
use nostr_sdk::prelude::*;
use state::get_client;
use tokio::sync::oneshot;
use ui::{
button::Button,
dock_area::panel::{Panel, PanelEvent},

View File

@@ -8,4 +8,3 @@ mod welcome;
pub mod app;
pub mod onboarding;
pub mod startup;

View File

@@ -1,12 +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 tokio::sync::oneshot;
use std::{path::PathBuf, sync::Arc, time::Duration};
use ui::{
button::{Button, ButtonCustomVariant, ButtonVariants},
input::{InputEvent, TextInput},
@@ -17,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> {
@@ -63,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);
}
},
)];
@@ -81,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();
@@ -161,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()
@@ -206,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(
@@ -332,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(
@@ -369,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)),
)
@@ -381,7 +376,7 @@ impl Render for Onboarding {
.text_lg()
.font_semibold()
.line_height(relative(1.2))
.child("Welcome to Coop!"),
.child(TITLE),
)
.child(
div()
@@ -389,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()
@@ -412,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),
)
}

View File

@@ -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,8 +8,7 @@ use gpui::{
use nostr_sdk::prelude::*;
use smol::fs;
use state::get_client;
use std::str::FromStr;
use tokio::sync::oneshot;
use std::{str::FromStr, sync::Arc, time::Duration};
use ui::{
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
@@ -18,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>,
@@ -33,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
})
}
@@ -165,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);
@@ -222,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)
}

View File

@@ -4,7 +4,6 @@ use gpui::{
};
use nostr_sdk::prelude::*;
use state::get_client;
use tokio::sync::oneshot;
use ui::{
button::{Button, ButtonVariants},
input::{InputEvent, TextInput},
@@ -66,41 +65,54 @@ impl Relays {
self.set_loading(true, cx);
let client = get_client();
let (tx, rx) = oneshot::channel();
cx.background_spawn(async move {
let signer = client.signer().await.expect("Signer is required");
let public_key = signer
.get_public_key()
.await
.expect("Cannot get public key");
// If user didn't have any NIP-65 relays, add default ones
// TODO: Is this really necessary?
if let Ok(relay_list) = client.database().relay_list(public_key).await {
if relay_list.is_empty() {
let builder = EventBuilder::relay_list(vec![
(RelayUrl::parse("wss://relay.damus.io/").unwrap(), None),
(RelayUrl::parse("wss://relay.primal.net/").unwrap(), None),
(RelayUrl::parse("wss://nos.lol/").unwrap(), None),
]);
if let Err(e) = client.send_event_builder(builder).await {
log::error!("Failed to send relay list event: {}", e)
}
}
}
let tags: Vec<Tag> = relays
.into_iter()
.map(|relay| Tag::custom(TagKind::Relay, vec![relay.to_string()]))
.collect();
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
if let Ok(output) = client.send_event_builder(builder).await {
_ = tx.send(output.val);
};
})
.detach();
cx.spawn(|this, mut cx| async move {
let (tx, rx) = oneshot::channel();
cx.background_spawn(async move {
let client = get_client();
let signer = client.signer().await.unwrap();
let public_key = signer.get_public_key().await.unwrap();
let tags: Vec<Tag> = relays
.into_iter()
.map(|relay| Tag::custom(TagKind::Relay, vec![relay.to_string()]))
.collect();
let event = EventBuilder::new(Kind::InboxRelays, "")
.tags(tags)
.build(public_key)
.sign(&signer)
.await
.unwrap();
if let Ok(output) = client.send_event(&event).await {
_ = tx.send(output.val);
};
})
.detach();
if rx.await.is_ok() {
cx.update_window(window_handle, |_, window, cx| {
window.close_modal(cx);
this.update(cx, |this, cx| {
_ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this, cx| {
this.set_loading(false, cx);
})
.unwrap();
})
.unwrap();
});
window.close_modal(cx);
});
}
})
.detach();

View File

@@ -1,8 +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,
@@ -13,7 +11,6 @@ use serde::Deserialize;
use smol::Timer;
use state::get_client;
use std::{collections::HashSet, time::Duration};
use tokio::sync::oneshot;
use ui::{
button::{Button, ButtonRounded},
input::{InputEvent, TextInput},
@@ -71,9 +68,13 @@ impl Compose {
subscriptions.push(cx.subscribe_in(
&user_input,
window,
move |this, _, input_event, window, cx| {
move |this, input, input_event, window, cx| {
if let InputEvent::PressEnter = input_event {
this.add(window, cx);
if input.read(cx).text().contains("@") {
this.add_nip05(window, cx);
} else {
this.add_npub(window, cx)
}
}
},
));
@@ -82,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();
@@ -145,7 +147,7 @@ impl Compose {
));
}
let tags = Tags::new(tag_list);
let tags = Tags::from_list(tag_list);
let client = get_client();
let window_handle = window.window_handle();
let (tx, rx) = oneshot::channel::<Event>();
@@ -174,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);
});
}
}
});
}
@@ -206,68 +210,133 @@ impl Compose {
self.is_submitting
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
fn add_npub(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let window_handle = window.window_handle();
let content = self.user_input.read(cx).text().to_string();
// Show loading spinner
self.set_loading(true, cx);
if let Ok(public_key) = PublicKey::parse(&content) {
if self
.contacts
.read(cx)
.iter()
.any(|c| c.public_key() == public_key)
{
self.set_loading(false, cx);
return;
};
let Ok(public_key) = PublicKey::parse(&content) else {
self.set_loading(false, cx);
self.set_error(Some("Public Key is not valid".into()), cx);
return;
};
cx.spawn(|this, mut cx| async move {
let (tx, rx) = oneshot::channel::<Metadata>();
if self
.contacts
.read(cx)
.iter()
.any(|c| c.public_key() == public_key)
{
self.set_loading(false, cx);
return;
};
cx.background_spawn(async move {
let client = get_client();
let client = get_client();
let (tx, rx) = oneshot::channel::<Metadata>();
cx.background_spawn(async move {
let metadata = (client
.fetch_metadata(public_key, Duration::from_secs(2))
.await)
.unwrap_or_default();
_ = tx.send(metadata);
})
.detach();
cx.spawn(|this, mut cx| async move {
if let Ok(metadata) = rx.await {
_ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this, cx| {
this.contacts.update(cx, |this, cx| {
this.insert(0, NostrProfile::new(public_key, metadata));
cx.notify();
});
this.selected.update(cx, |this, cx| {
this.insert(public_key);
cx.notify();
});
// Stop loading indicator
this.set_loading(false, cx);
// Clear input
this.user_input.update(cx, |this, cx| {
this.set_text("", window, cx);
cx.notify();
});
});
});
}
})
.detach();
}
fn add_nip05(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let window_handle = window.window_handle();
let content = self.user_input.read(cx).text().to_string();
// Show loading spinner
self.set_loading(true, cx);
let client = get_client();
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
cx.background_spawn(async move {
spawn(async move {
if let Ok(profile) = nip05::profile(&content, None).await {
let metadata = (client
.fetch_metadata(public_key, Duration::from_secs(3))
.fetch_metadata(profile.public_key, Duration::from_secs(2))
.await)
.unwrap_or_default();
_ = tx.send(metadata);
})
.detach();
_ = tx.send(Some(NostrProfile::new(profile.public_key, metadata)));
} else {
_ = tx.send(None);
}
});
})
.detach();
if let Ok(metadata) = rx.await {
_ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this, cx| {
this.contacts.update(cx, |this, cx| {
this.insert(0, NostrProfile::new(public_key, metadata));
cx.notify();
});
cx.spawn(|this, mut cx| async move {
if let Ok(Some(profile)) = rx.await {
_ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this, cx| {
let public_key = profile.public_key();
this.selected.update(cx, |this, cx| {
this.insert(public_key);
cx.notify();
});
this.contacts.update(cx, |this, cx| {
this.insert(0, profile);
cx.notify();
});
// Stop loading indicator
this.set_loading(false, cx);
this.selected.update(cx, |this, cx| {
this.insert(public_key);
cx.notify();
});
// Clear input
this.user_input.update(cx, |this, cx| {
this.set_text("", window, cx);
cx.notify();
});
// Stop loading indicator
this.set_loading(false, cx);
// Clear input
this.user_input.update(cx, |this, cx| {
this.set_text("", window, cx);
cx.notify();
});
});
}
})
.detach();
} else {
self.set_loading(false, cx);
self.set_error(Some("Public Key is not valid".into()), cx);
}
});
} else {
_ = cx.update_window(window_handle, |_, _, cx| {
_ = this.update(cx, |this, cx| {
this.set_loading(false, cx);
this.set_error(Some("NIP-05 Address is not valid".into()), cx);
});
});
}
})
.detach();
}
fn set_error(&mut self, error: Option<SharedString>, cx: &mut Context<Self>) {
@@ -372,9 +441,13 @@ impl Render for Compose {
.small()
.rounded(ButtonRounded::Size(px(9999.)))
.loading(self.is_loading)
.on_click(
cx.listener(|this, _, window, cx| this.add(window, cx)),
),
.on_click(cx.listener(|this, _, window, cx| {
if this.user_input.read(cx).text().contains("@") {
this.add_nip05(window, cx);
} else {
this.add_npub(window, cx);
}
})),
)
.child(self.user_input.clone()),
)

View File

@@ -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))
})
}
}

View File

@@ -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())
}
}

View File

@@ -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)),
)
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "chats"
version = "0.1.0"
version = "0.0.0"
edition = "2021"
publish = false
@@ -12,5 +12,7 @@ gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
smol.workspace = true
chrono.workspace = true
smallvec.workspace = true
smol.workspace = true
oneshot.workspace = true

View File

@@ -1,13 +1,11 @@
use crate::room::Room;
use anyhow::anyhow;
use async_utility::tokio::sync::oneshot;
use common::utils::{compare, room_hash, signer_public_key};
use gpui::{App, AppContext, Context, Entity, Global};
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);
@@ -18,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,
}
@@ -30,43 +28,16 @@ impl ChatRegistry {
pub fn register(cx: &mut App) -> Entity<Self> {
Self::global(cx).unwrap_or_else(|| {
let entity = cx.new(Self::new);
let entity = cx.new(|cx| {
let mut this = Self::new(cx);
// Automatically load chat rooms the database when the registry is created
this.load_chat_rooms(cx);
this
});
// 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
})
@@ -74,91 +45,119 @@ impl ChatRegistry {
fn new(_cx: &mut Context<Self>) -> Self {
Self {
rooms: Vec::with_capacity(5),
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 {
_ = cx.update(|cx| {
_ = this.update(cx, |this, cx| {
let current_rooms = this.current_rooms_ids(cx);
let items: Vec<Entity<Room>> = events
.into_iter()
.filter_map(|ev| {
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)))
} else {
None
}
})
.collect();
if let Ok(Some(events)) = rx.await {
if !events.is_empty() {
_ = cx.update(|cx| {
_ = this.update(cx, |this, cx| {
let current_rooms = this.current_rooms_ids(cx);
let items: Vec<Entity<Room>> = events
.into_iter()
.filter_map(|ev| {
let new = room_hash(&ev);
// Filter all seen events
if !current_rooms.iter().any(|this| this == &new) {
Some(Room::new(&ev, cx))
} else {
None
}
})
.collect();
this.rooms.extend(items);
this.is_loading = false;
this.rooms.write().unwrap().extend(items);
this.is_loading = false;
cx.notify();
cx.notify();
});
});
});
}
}
})
.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 {
self.is_loading
}
pub fn get(&self, id: &u64, cx: &App) -> Option<Entity<Room>> {
pub fn get(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
self.rooms
.read()
.unwrap()
.iter()
.find(|model| &model.read(cx).id == id)
.cloned()
.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(&current.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(())
@@ -168,26 +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
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();
}
}

View File

@@ -1,134 +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()
}
/// 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());
/// Determine if room is a group
pub fn is_group(&self) -> bool {
self.members.len() > 2
}
pubkeys
/// Get room's last seen
pub fn last_seen(&self) -> Rc<LastSeen> {
self.last_seen.clone()
}
/// Get room's last seen as ago format
pub fn ago(&self) -> SharedString {
self.last_seen.ago()
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "common"
version = "0.1.0"
version = "0.0.0"
edition = "2021"
publish = false

View File

@@ -2,8 +2,6 @@ pub const KEYRING_SERVICE: &str = "Coop Safe Storage";
pub const APP_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop";
pub const FAKE_SIG: &str = "f9e79d141c004977192d05a86f81ec7c585179c371f7350a5412d33575a2a356433f58e405c2296ed273e2fe0aafa25b641e39cc4e1f3f261ebf55bce0cbac83";
/// Subscriptions
pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps";
pub const ALL_MESSAGES_SUB_ID: &str = "listen_all_giftwraps";

View File

@@ -1,54 +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) {

View File

@@ -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.jpg".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()
}
}

View File

@@ -1,61 +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 subscription = Filter::new()
.kind(Kind::ContactList)
.author(public_key)
.limit(1);
// Get contact list
_ = client.sync(subscription, &SyncOptions::default()).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)?;
@@ -67,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()
}

View File

@@ -1,10 +1,9 @@
[package]
name = "state"
version = "0.1.0"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
nostr-sdk.workspace = true
tokio.workspace = true
dirs.workspace = true

View File

@@ -4,25 +4,26 @@ use std::{fs, sync::OnceLock, time::Duration};
static CLIENT: OnceLock<Client> = OnceLock::new();
pub fn initialize_client() {
// Setup app data folder
let config_dir = config_dir().expect("Config directory not found");
let _ = fs::create_dir_all(config_dir.join("Coop/"));
// Setup database
let lmdb = NostrLMDB::open(config_dir.join("Coop/nostr")).expect("Database is NOT initialized");
// Client options
let opts = Options::new()
.gossip(true)
.max_avg_latency(Duration::from_secs(2));
// Setup Nostr Client
let client = ClientBuilder::default().database(lmdb).opts(opts).build();
CLIENT.set(client).expect("Client is already initialized!");
}
pub fn get_client() -> &'static Client {
CLIENT.get().expect("Client is NOT initialized!")
CLIENT.get_or_init(|| {
// Setup app data folder
let config_dir = config_dir().expect("Config directory not found");
let app_dir = config_dir.join("Coop/");
// Create app directory if it doesn't exist
_ = fs::create_dir_all(&app_dir);
// Setup database
let lmdb = NostrLMDB::open(app_dir.join("nostr")).expect("Database is NOT initialized");
// Client options
let opts = Options::new()
// NIP-65
.gossip(true)
// Skip all very slow relays
.max_avg_latency(Duration::from_millis(800));
// Setup Nostr Client
ClientBuilder::default().database(lmdb).opts(opts).build()
})
}

View File

@@ -1,6 +1,6 @@
[package]
name = "ui"
version = "0.1.0"
version = "0.0.0"
edition = "2021"
publish = false

View File

@@ -1,5 +1,5 @@
use gpui::{
div, prelude::FluentBuilder as _, px, AnyView, App, AppContext, Axis, Context, Element, Entity,
div, prelude::FluentBuilder as _, px, App, AppContext, Axis, Context, Element, Entity,
InteractiveElement as _, IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels,
Point, Render, StatefulInteractiveElement, Style, Styled as _, WeakEntity, Window,
};
@@ -358,8 +358,6 @@ impl Render for Dock {
return div();
}
let cache_style = gpui::StyleRefinement::default().v_flex().size_full();
div()
.relative()
.overflow_hidden()
@@ -374,10 +372,8 @@ impl Render for Dock {
})
.map(|this| match &self.panel {
DockItem::Split { view, .. } => this.child(view.clone()),
DockItem::Tabs { view, .. } => {
this.child(AnyView::from(view.clone()).cached(cache_style))
}
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)),
DockItem::Tabs { view, .. } => this.child(view.clone()),
DockItem::Panel { view, .. } => this.child(view.clone().view()),
})
.child(self.render_resize_handle(window, cx))
.child(DockElement {
@@ -432,14 +428,20 @@ impl Element for DockElement {
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut gpui::Window,
_: &mut App,
cx: &mut App,
) {
window.on_mouse_event({
let view = self.view.clone();
let is_resizing = view.read(cx).is_resizing;
move |e: &MouseMoveEvent, phase, window, cx| {
if phase.bubble() {
view.update(cx, |view, cx| view.resize(e.position, window, cx))
if !is_resizing {
return;
}
if !phase.bubble() {
return;
}
view.update(cx, |view, cx| view.resize(e.position, window, cx))
}
});

View File

@@ -244,7 +244,7 @@ impl RenderOnce for Modal {
.with_easing(cubic_bezier(0.32, 0.72, 0., 1.)),
move |this, delta| {
let y_offset = px(0.) + delta * px(30.);
this.top(y + y_offset).opacity(delta)
this.top(y + y_offset)
},
),
),

View File

@@ -133,7 +133,6 @@ impl PopupMenu {
this.dismiss(&Dismiss, window, cx)
}),
];
let menu = Self {
focus_handle,
action_focus_handle: None,
@@ -150,7 +149,7 @@ impl PopupMenu {
scroll_state: Rc::new(Cell::new(ScrollbarState::default())),
subscriptions,
};
window.refresh();
f(menu, window, cx)
})
}

View File

@@ -510,41 +510,38 @@ impl Element for ResizePanelGroupElement {
let axis = self.axis;
let current_ix = view.read(cx).resizing_panel_ix;
move |e: &MouseMoveEvent, phase, window, cx| {
if phase.bubble() {
if let Some(ix) = current_ix {
view.update(cx, |view, cx| {
let panel = view
.panels
.get(ix)
.expect("BUG: invalid panel index")
.read(cx);
match axis {
Axis::Horizontal => view.resize_panels(
ix,
e.position.x - panel.bounds.left(),
window,
cx,
),
Axis::Vertical => {
view.resize_panels(
ix,
e.position.y - panel.bounds.top(),
window,
cx,
);
}
}
})
}
if !phase.bubble() {
return;
}
let Some(ix) = current_ix else { return };
view.update(cx, |view, cx| {
let panel = view
.panels
.get(ix)
.expect("BUG: invalid panel index")
.read(cx);
match axis {
Axis::Horizontal => {
view.resize_panels(ix, e.position.x - panel.bounds.left(), window, cx)
}
Axis::Vertical => {
view.resize_panels(ix, e.position.y - panel.bounds.top(), window, cx);
}
}
})
}
});
// When any mouse up, stop dragging
window.on_mouse_event({
let view = self.view.clone();
let current_ix = view.read(cx).resizing_panel_ix;
move |_: &MouseUpEvent, phase, window, cx| {
if current_ix.is_none() {
return;
}
if phase.bubble() {
view.update(cx, |view, cx| view.done_resizing(window, cx));
}

View File

@@ -1,6 +1,14 @@
use gpui::*;
use gpui::{
fill, point, px, relative, App, Bounds, ContentMask, CursorStyle, Edges, Element, EntityId,
Hitbox, Hsla, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels,
Point, Position, ScrollHandle, ScrollWheelEvent, UniformListScrollHandle, Window,
};
use serde::{Deserialize, Serialize};
use std::{cell::Cell, rc::Rc, time::Instant};
use std::{
cell::Cell,
rc::Rc,
time::{Duration, Instant},
};
use crate::theme::{scale::ColorScaleStep, ActiveTheme};
@@ -10,18 +18,24 @@ pub enum ScrollbarShow {
#[default]
Scrolling,
Hover,
Always,
}
impl ScrollbarShow {
fn is_hover(&self) -> bool {
matches!(self, Self::Hover)
}
fn is_always(&self) -> bool {
matches!(self, Self::Always)
}
}
const BORDER_WIDTH: Pixels = px(0.);
pub(crate) const WIDTH: Pixels = px(12.);
const MIN_THUMB_SIZE: f32 = 80.;
const THUMB_RADIUS: Pixels = Pixels(3.0);
const THUMB_INSET: Pixels = Pixels(4.);
const THUMB_RADIUS: Pixels = Pixels(4.0);
const THUMB_INSET: Pixels = Pixels(3.);
const FADE_OUT_DURATION: f32 = 3.0;
const FADE_OUT_DELAY: f32 = 2.0;
@@ -65,6 +79,8 @@ pub struct ScrollbarState {
drag_pos: Point<Pixels>,
last_scroll_offset: Point<Pixels>,
last_scroll_time: Option<Instant>,
// Last update offset
last_update: Instant,
}
impl Default for ScrollbarState {
@@ -76,6 +92,7 @@ impl Default for ScrollbarState {
drag_pos: point(px(0.), px(0.)),
last_scroll_offset: point(px(0.), px(0.)),
last_scroll_time: None,
last_update: Instant::now(),
}
}
}
@@ -106,8 +123,8 @@ impl ScrollbarState {
fn with_hovered(&self, axis: Option<ScrollbarAxis>) -> Self {
let mut state = *self;
state.hovered_axis = axis;
if self.is_scrollbar_visible() {
state.last_scroll_time = Some(Instant::now());
if axis.is_some() {
state.last_scroll_time = Some(std::time::Instant::now());
}
state
}
@@ -115,6 +132,9 @@ impl ScrollbarState {
fn with_hovered_on_thumb(&self, axis: Option<ScrollbarAxis>) -> Self {
let mut state = *self;
state.hovered_on_thumb = axis;
if axis.is_some() {
state.last_scroll_time = Some(std::time::Instant::now());
}
state
}
@@ -135,7 +155,18 @@ impl ScrollbarState {
state
}
fn with_last_update(&self, t: Instant) -> Self {
let mut state = *self;
state.last_update = t;
state
}
fn is_scrollbar_visible(&self) -> bool {
// On drag
if self.dragged_axis.is_some() {
return true;
}
if let Some(last_time) = self.last_scroll_time {
let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
elapsed < FADE_OUT_DURATION
@@ -178,9 +209,9 @@ impl ScrollbarAxis {
match self {
Self::Vertical => vec![Self::Vertical],
Self::Horizontal => vec![Self::Horizontal],
// This should keep vertical first, vertical is the primary axis
// if vertical not need display, then horizontal will not keep right margin.
Self::Both => vec![Self::Vertical, Self::Horizontal],
// This should keep Horizontal first, Vertical is the primary axis
// if Vertical not need display, then Horizontal will not keep right margin.
Self::Both => vec![Self::Horizontal, Self::Vertical],
}
}
}
@@ -189,11 +220,14 @@ impl ScrollbarAxis {
pub struct Scrollbar {
view_id: EntityId,
axis: ScrollbarAxis,
/// When is vertical, this is the height of the scrollbar.
width: Pixels,
scroll_handle: Rc<Box<dyn ScrollHandleOffsetable>>,
scroll_size: gpui::Size<Pixels>,
state: Rc<Cell<ScrollbarState>>,
/// Maximum frames per second for scrolling by drag. Default is 120 FPS.
///
/// This is used to limit the update rate of the scrollbar when it is
/// being dragged for some complex interactions for reducing CPU usage.
max_fps: usize,
}
impl Scrollbar {
@@ -209,8 +243,8 @@ impl Scrollbar {
state,
axis,
scroll_size,
width: px(12.),
scroll_handle: Rc::new(Box::new(scroll_handle)),
max_fps: 120,
}
}
@@ -290,11 +324,21 @@ impl Scrollbar {
self
}
/// Set maximum frames per second for scrolling by drag. Default is 120 FPS.
///
/// If you have very high CPU usage, consider reducing this value to improve performance.
///
/// Available values: 30..120
pub fn max_fps(mut self, max_fps: usize) -> Self {
self.max_fps = max_fps.clamp(30, 120);
self
}
fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels) {
(
cx.theme().scrollbar_thumb_hover,
cx.theme().scrollbar,
cx.theme().base.step(cx, ColorScaleStep::THREE),
cx.theme().base.step(cx, ColorScaleStep::SEVEN),
THUMB_INSET - px(1.),
THUMB_RADIUS,
)
@@ -304,7 +348,7 @@ impl Scrollbar {
(
cx.theme().scrollbar_thumb_hover,
cx.theme().scrollbar,
cx.theme().base.step(cx, ColorScaleStep::THREE),
cx.theme().base.step(cx, ColorScaleStep::SIX),
THUMB_INSET - px(1.),
THUMB_RADIUS,
)
@@ -382,11 +426,11 @@ impl Element for Scrollbar {
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let style = Style {
let style = gpui::Style {
position: Position::Absolute,
flex_grow: 1.0,
flex_shrink: 1.0,
size: Size {
size: gpui::Size {
width: relative(1.).into(),
height: relative(1.).into(),
},
@@ -409,7 +453,6 @@ impl Element for Scrollbar {
});
let mut states = vec![];
let mut has_both = self.axis.is_both();
for axis in self.axis.all().into_iter() {
@@ -430,7 +473,7 @@ impl Element for Scrollbar {
// The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible.
let margin_end = if has_both && !is_vertical {
self.width
WIDTH
} else {
px(0.)
};
@@ -449,31 +492,29 @@ impl Element for Scrollbar {
let bounds = Bounds {
origin: if is_vertical {
point(
hitbox.origin.x + hitbox.size.width - self.width,
hitbox.origin.y,
)
point(hitbox.origin.x + hitbox.size.width - WIDTH, hitbox.origin.y)
} else {
point(
hitbox.origin.x,
hitbox.origin.y + hitbox.size.height - self.width,
hitbox.origin.y + hitbox.size.height - WIDTH,
)
},
size: gpui::Size {
width: if is_vertical {
self.width
WIDTH
} else {
hitbox.size.width
},
height: if is_vertical {
hitbox.size.height
} else {
self.width
WIDTH
},
},
};
let state = self.state.clone();
let is_always_to_show = cx.theme().scrollbar_show.is_always();
let is_hover_to_show = cx.theme().scrollbar_show.is_hover();
let is_hovered_on_bar = state.get().hovered_axis == Some(axis);
let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis);
@@ -481,7 +522,9 @@ impl Element for Scrollbar {
let (thumb_bg, bar_bg, bar_border, inset, radius) =
if state.get().dragged_axis == Some(axis) {
Self::style_for_active(cx)
} else if is_hover_to_show && is_hovered_on_bar {
} else if (is_hover_to_show || is_always_to_show)
&& (is_hovered_on_bar || is_hovered_on_thumb)
{
if is_hovered_on_thumb {
Self::style_for_hovered_thumb(cx)
} else {
@@ -520,12 +563,12 @@ impl Element for Scrollbar {
let thumb_bounds = if is_vertical {
Bounds::from_corners(
point(bounds.origin.x, bounds.origin.y + thumb_start),
point(bounds.origin.x + self.width, bounds.origin.y + thumb_end),
point(bounds.origin.x + WIDTH, bounds.origin.y + thumb_end),
)
} else {
Bounds::from_corners(
point(bounds.origin.x + thumb_start, bounds.origin.y),
point(bounds.origin.x + thumb_end, bounds.origin.y + self.width),
point(bounds.origin.x + thumb_end, bounds.origin.y + WIDTH),
)
};
let thumb_fill_bounds = if is_vertical {
@@ -535,7 +578,7 @@ impl Element for Scrollbar {
bounds.origin.y + thumb_start + inset,
),
point(
bounds.origin.x + self.width - inset,
bounds.origin.x + WIDTH - inset,
bounds.origin.y + thumb_end - inset,
),
)
@@ -547,7 +590,7 @@ impl Element for Scrollbar {
),
point(
bounds.origin.x + thumb_end - inset,
bounds.origin.y + self.width - inset,
bounds.origin.y + WIDTH - inset,
),
)
};
@@ -589,6 +632,15 @@ impl Element for Scrollbar {
let is_visible = self.state.get().is_scrollbar_visible();
let is_hover_to_show = cx.theme().scrollbar_show.is_hover();
// Update last_scroll_time when offset is changed.
if self.scroll_handle.offset() != self.state.get().last_scroll_offset {
self.state.set(
self.state
.get()
.with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())),
);
}
window.with_content_mask(
Some(ContentMask {
bounds: hitbox_bounds,
@@ -711,30 +763,36 @@ impl Element for Scrollbar {
let scroll_handle = self.scroll_handle.clone();
let state = self.state.clone();
let view_id = self.view_id;
let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64);
move |event: &MouseMoveEvent, _, _, cx| {
let mut notify = false;
// When is hover to show mode or it was visible,
// we need to update the hovered state and increase the last_scroll_time.
let need_hover_to_update = is_hover_to_show || is_visible;
// Update hovered state for scrollbar
if bounds.contains(&event.position) {
if bounds.contains(&event.position) && need_hover_to_update {
state.set(state.get().with_hovered(Some(axis)));
if state.get().hovered_axis != Some(axis) {
state.set(state.get().with_hovered(Some(axis)));
cx.notify(view_id);
notify = true;
}
} else if state.get().hovered_axis == Some(axis)
&& state.get().hovered_axis.is_some()
{
state.set(state.get().with_hovered(None));
cx.notify(view_id);
notify = true;
}
// Update hovered state for scrollbar thumb
if thumb_bounds.contains(&event.position) {
if state.get().hovered_on_thumb != Some(axis) {
state.set(state.get().with_hovered_on_thumb(Some(axis)));
cx.notify(view_id);
notify = true;
}
} else if state.get().hovered_on_thumb == Some(axis) {
state.set(state.get().with_hovered_on_thumb(None));
cx.notify(view_id);
notify = true;
}
// Move thumb position on dragging
@@ -769,10 +827,18 @@ impl Element for Scrollbar {
if (scroll_handle.offset().y - offset.y).abs() > px(1.)
|| (scroll_handle.offset().x - offset.x).abs() > px(1.)
{
scroll_handle.set_offset(offset);
cx.notify(view_id);
// Limit update rate
if state.get().last_update.elapsed() > max_fps_duration {
scroll_handle.set_offset(offset);
state.set(state.get().with_last_update(Instant::now()));
notify = true;
}
}
}
if notify {
cx.notify(view_id);
}
}
});

View File

@@ -121,7 +121,6 @@ impl RenderOnce for WindowBorder {
.when(!tiling.bottom, |div| div.pb(SHADOW_SIZE))
.when(!tiling.left, |div| div.pl(SHADOW_SIZE))
.when(!tiling.right, |div| div.pr(SHADOW_SIZE))
.on_mouse_move(|_e, window, _cx| window.refresh())
.on_mouse_down(MouseButton::Left, move |_, window, _cx| {
let size = window.window_bounds().get_bounds().size;
let pos = window.mouse_position();