14 Commits

Author SHA1 Message Date
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
28 changed files with 1064 additions and 880 deletions

754
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,6 @@ coop = { path = "crates/*" }
# UI # UI
gpui = { git = "https://github.com/zed-industries/zed" } 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" } reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr # 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-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
"lmdb", "lmdb",
"all-nips", "nip96",
"nip59",
"nip49",
"nip44",
"nip05",
] } ] }
smol = "2" smol = "2"
tokio = { version = "1", features = ["full"] } oneshot = { git = "https://github.com/faern/oneshot" }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
dirs = "5.0" dirs = "5.0"
@@ -31,6 +34,7 @@ tracing = "0.1.40"
anyhow = "1.0.44" anyhow = "1.0.44"
smallvec = "1.13.2" smallvec = "1.13.2"
rust-embed = "8.5.0" rust-embed = "8.5.0"
log = "0.4"
[profile.release] [profile.release]
strip = true 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." description = "Coop is a cross-platform Nostr client designed for secure communication focus on simplicity and customizability."
product-name = "Coop" product-name = "Coop"
identifier = "su.reya.coop" identifier = "su.reya.coop"
version = "0.1.0" version = "0.1.2"
resources = ["assets/*/*", "Cargo.toml", "./LICENSE", "./README.md"] resources = ["assets/*/*", "Cargo.toml", "./LICENSE", "./README.md"]
icons = [ icons = [
"assets/brand/32x32.png", "assets/brand/32x32.png",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "coop" name = "coop"
version = "0.1.0" version = "0.1.2"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -15,10 +15,8 @@ state = { path = "../state" }
chats = { path = "../chats" } chats = { path = "../chats" }
gpui.workspace = true gpui.workspace = true
gpui_tokio.workspace = true
reqwest_client.workspace = true reqwest_client.workspace = true
tokio.workspace = true
nostr-connect.workspace = true nostr-connect.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
anyhow.workspace = true anyhow.workspace = true
@@ -27,8 +25,10 @@ serde_json.workspace = true
itertools.workspace = true itertools.workspace = true
dirs.workspace = true dirs.workspace = true
rust-embed.workspace = true rust-embed.workspace = true
log.workspace = true
smol.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"] } tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
log = "0.4"

View File

@@ -1,12 +1,10 @@
use asset::Assets; use asset::Assets;
use async_utility::task::spawn;
use chats::registry::ChatRegistry; use chats::registry::ChatRegistry;
use common::{ use common::{
constants::{ constants::{ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID},
ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, FAKE_SIG, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID,
},
profile::NostrProfile, profile::NostrProfile,
}; };
use futures::{select, FutureExt};
use gpui::{ use gpui::{
actions, px, size, App, AppContext, Application, AsyncApp, Bounds, KeyBinding, Menu, MenuItem, actions, px, size, App, AppContext, Application, AsyncApp, Bounds, KeyBinding, Menu, MenuItem,
WindowBounds, WindowKind, WindowOptions, WindowBounds, WindowKind, WindowOptions,
@@ -16,20 +14,24 @@ use gpui::{point, SharedString, TitlebarOptions};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use gpui::{WindowBackgroundAppearance, WindowDecorations}; use gpui::{WindowBackgroundAppearance, WindowDecorations};
use log::{error, info}; use log::{error, info};
use nostr_sdk::prelude::*; use nostr_sdk::{
pool::prelude::ReqExitPolicy, Client, Event, Filter, Keys, Kind, Metadata, PublicKey,
RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions,
};
use nostr_sdk::{prelude::NostrEventsDatabaseExt, FromBech32, SubscriptionId};
use smol::Timer;
use state::{get_client, initialize_client}; use state::{get_client, initialize_client};
use std::{borrow::Cow, collections::HashSet, str::FromStr, sync::Arc, time::Duration}; use std::{collections::HashSet, mem, sync::Arc, time::Duration};
use tokio::sync::{mpsc, oneshot};
use ui::{theme::Theme, Root}; use ui::{theme::Theme, Root};
use views::{app, onboarding, startup}; use views::{app, onboarding, startup};
mod asset; mod asset;
mod views; mod views;
actions!(main_menu, [Quit]); actions!(coop, [Quit]);
#[derive(Clone)] #[derive(Clone)]
pub enum Signal { enum Signal {
/// Receive event /// Receive event
Event(Event), Event(Event),
/// Receive EOSE /// Receive EOSE
@@ -37,209 +39,166 @@ pub enum Signal {
} }
fn main() { fn main() {
// Initialize Nostr client // Fix crash on startup
initialize_client(); // 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>(2048);
let client = get_client(); let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(100);
let (signal_tx, mut signal_rx) = tokio::sync::mpsc::channel::<Signal>(2048);
spawn(async move { // Initialize nostr client
// Add some bootstrap relays let client = initialize_client();
_ = 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() let app = Application::new()
.with_assets(Assets) .with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new())); .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| { app.on_reopen(move |cx| {
let client = get_client(); let client = get_client();
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>(); let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
cx.spawn(|mut cx| async move { cx.background_spawn(async move {
cx.background_spawn(async move { if let Ok(signer) = client.signer().await {
if let Ok(signer) = client.signer().await { if let Ok(public_key) = signer.get_public_key().await {
if let Ok(public_key) = signer.get_public_key().await { let metadata =
let metadata = if let Ok(Some(metadata)) = if let Ok(Some(metadata)) = client.database().metadata(public_key).await {
client.database().metadata(public_key).await
{
metadata metadata
} else { } else {
Metadata::new() Metadata::new()
}; };
_ = tx.send(Some(NostrProfile::new(public_key, metadata))); _ = tx.send(Some(NostrProfile::new(public_key, metadata)));
} else {
_ = tx.send(None);
}
} else { } else {
_ = tx.send(None); _ = tx.send(None);
} }
}) } else {
.detach(); _ = tx.send(None);
}
})
.detach();
cx.spawn(|mut cx| async move {
if let Ok(result) = rx.await { if let Ok(result) = rx.await {
_ = restore_window(result, &mut cx).await; _ = restore_window(result, &mut cx).await;
} }
@@ -264,116 +223,131 @@ fn main() {
items: vec![MenuItem::action("Quit", Quit)], items: vec![MenuItem::action("Quit", Quit)],
}]); }]);
let opts = WindowOptions { // Open window with default options
#[cfg(not(target_os = "linux"))] cx.open_window(
titlebar: Some(TitlebarOptions { WindowOptions {
title: Some(SharedString::new_static(APP_NAME)), #[cfg(not(target_os = "linux"))]
traffic_light_position: Some(point(px(9.0), px(9.0))), titlebar: Some(TitlebarOptions {
appears_transparent: true, title: Some(SharedString::new_static(APP_NAME)),
}), traffic_light_position: Some(point(px(9.0), px(9.0))),
window_bounds: Some(WindowBounds::Windowed(Bounds::centered( appears_transparent: true,
None, }),
size(px(900.0), px(680.0)), window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
cx, None,
))), size(px(900.0), px(680.0)),
#[cfg(target_os = "linux")] cx,
window_background: WindowBackgroundAppearance::Transparent, ))),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
window_decorations: Some(WindowDecorations::Client), window_background: WindowBackgroundAppearance::Transparent,
kind: WindowKind::Normal, #[cfg(target_os = "linux")]
..Default::default() window_decorations: Some(WindowDecorations::Client),
}; kind: WindowKind::Normal,
..Default::default()
},
|window, cx| {
window.set_window_title(APP_NAME);
window.set_app_id(APP_ID);
cx.open_window(opts, |window, cx| { #[cfg(not(target_os = "linux"))]
window.set_window_title(APP_NAME); window
window.set_app_id(APP_ID); .observe_window_appearance(|window, cx| {
window Theme::sync_system_appearance(Some(window), cx);
.observe_window_appearance(|window, cx| { })
Theme::sync_system_appearance(Some(window), cx); .detach();
})
.detach();
let handle = window.window_handle(); let handle = window.window_handle();
let root = cx.new(|cx| Root::new(startup::init(window, cx).into(), window, cx)); let root = cx.new(|cx| Root::new(startup::init(window, cx).into(), window, cx));
let task = cx.read_credentials(KEYRING_SERVICE); let task = cx.read_credentials(KEYRING_SERVICE);
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>(); let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
// Read credential in OS Keyring // Read credential in OS Keyring
cx.background_spawn(async { cx.background_spawn(async {
let profile = if let Ok(Some((npub, secret))) = task.await { let profile = if let Ok(Some((npub, secret))) = task.await {
let public_key = PublicKey::from_bech32(&npub).unwrap(); let public_key = PublicKey::from_bech32(&npub).unwrap();
let secret_hex = String::from_utf8(secret).unwrap(); let secret_hex = String::from_utf8(secret).unwrap();
let keys = Keys::parse(&secret_hex).unwrap(); let keys = Keys::parse(&secret_hex).unwrap();
// Update nostr signer // Update nostr signer
_ = client.set_signer(keys).await; _ = client.set_signer(keys).await;
// Get user's metadata // Get user's metadata
let metadata = let metadata = if let Ok(Some(metadata)) =
if let Ok(Some(metadata)) = client.database().metadata(public_key).await { client.database().metadata(public_key).await
{
metadata metadata
} else { } else {
Metadata::new() Metadata::new()
}; };
Some(NostrProfile::new(public_key, metadata)) Some(NostrProfile::new(public_key, metadata))
} else { } else {
None None
}; };
_ = tx.send(profile) _ = tx.send(profile)
}) })
.detach(); .detach();
// Set root view based on credential status // Set root view based on credential status
cx.spawn(|mut cx| async move { cx.spawn(|mut cx| async move {
if let Ok(Some(profile)) = rx.await { if let Ok(Some(profile)) = rx.await {
_ = cx.update_window(handle, |_, window, cx| { _ = cx.update_window(handle, |_, window, cx| {
window.replace_root(cx, |window, cx| { window.replace_root(cx, |window, cx| {
Root::new(app::init(profile, window, cx).into(), window, cx) Root::new(app::init(profile, window, cx).into(), window, cx)
});
});
} else {
_ = cx.update_window(handle, |_, window, cx| {
window.replace_root(cx, |window, cx| {
Root::new(onboarding::init(window, cx).into(), window, cx)
});
});
}
})
.detach();
// 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) => { } else {
_ = cx.update(|cx| { _ = cx.update_window(handle, |_, window, cx| {
if let Some(chats) = ChatRegistry::global(cx) { window.replace_root(cx, |window, cx| {
chats.update(cx, |this, cx| this.push_message(event, cx)) Root::new(onboarding::init(window, cx).into(), window, cx)
}
}); });
} });
} }
} })
}) .detach();
.detach();
root cx.spawn(|cx| async move {
}) while let Ok(signal) = event_rx.recv().await {
cx.update(|cx| {
match signal {
Signal::Eose => {
if let Some(chats) = ChatRegistry::global(cx) {
chats.update(cx, |this, cx| this.load_chat_rooms(cx))
}
}
Signal::Event(event) => {
if let Some(chats) = ChatRegistry::global(cx) {
chats.update(cx, |this, cx| this.push_message(event, cx))
}
}
};
})
.ok();
}
})
.detach();
root
},
)
.expect("System error. Please re-open the app."); .expect("System error. Please re-open the app.");
}); });
} }
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(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> anyhow::Result<()> {
let opts = cx let opts = cx
.update(|cx| WindowOptions { .update(|cx| WindowOptions {
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
@@ -400,6 +374,8 @@ async fn restore_window(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> Res
_ = cx.open_window(opts, |window, cx| { _ = cx.open_window(opts, |window, cx| {
window.set_window_title(APP_NAME); window.set_window_title(APP_NAME);
window.set_app_id(APP_ID); window.set_app_id(APP_ID);
#[cfg(not(target_os = "linux"))]
window window
.observe_window_appearance(|window, cx| { .observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx); Theme::sync_system_appearance(Some(window), cx);

View File

@@ -1,19 +1,13 @@
use cargo_packager_updater::{check_update, semver::Version, url::Url}; use common::profile::NostrProfile;
use common::{
constants::{UPDATER_PUBKEY, UPDATER_URL},
profile::NostrProfile,
};
use gpui::{ use gpui::{
actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis, actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis,
Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled, Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled,
StyledImage, Window, StyledImage, Window,
}; };
use log::info;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use state::get_client; use state::get_client;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::oneshot;
use ui::{ use ui::{
button::{Button, ButtonRounded, ButtonVariants}, button::{Button, ButtonRounded, ButtonVariants},
dock_area::{dock::DockPlacement, DockArea, DockItem}, dock_area::{dock::DockPlacement, DockArea, DockItem},
@@ -88,26 +82,6 @@ impl AppView {
view.set_center(center_panel, window, cx); 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| { cx.new(|cx| {
let public_key = account.public_key(); let public_key = account.public_key();
let relays = cx.new(|_| None); let relays = cx.new(|_| None);
@@ -188,7 +162,7 @@ impl AppView {
this.keyboard(false) this.keyboard(false)
.closable(false) .closable(false)
.width(px(420.)) .width(px(420.))
.title("Your Messaging Relays is not configured") .title("Your Messaging Relays are not configured")
.child(relays.clone()) .child(relays.clone())
.footer( .footer(
div() div()

View File

@@ -19,7 +19,6 @@ use nostr_sdk::prelude::*;
use smol::fs; use smol::fs;
use state::get_client; use state::get_client;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::oneshot;
use ui::{ use ui::{
button::{Button, ButtonRounded, ButtonVariants}, button::{Button, ButtonRounded, ButtonVariants},
dock_area::panel::{Panel, PanelEvent}, dock_area::panel::{Panel, PanelEvent},
@@ -50,22 +49,39 @@ pub fn init(
} }
#[derive(PartialEq, Eq)] #[derive(PartialEq, Eq)]
struct ChatItem { struct ParsedMessage {
profile: NostrProfile, avatar: SharedString,
display_name: SharedString,
created_at: SharedString,
content: SharedString, content: SharedString,
ago: SharedString, }
impl ParsedMessage {
pub fn new(profile: &NostrProfile, content: &str, created_at: Timestamp) -> Self {
let avatar = profile.avatar().into();
let display_name = profile.name().into();
let content = SharedString::new(content);
let created_at = LastSeen(created_at).human_readable();
Self {
avatar,
display_name,
created_at,
content,
}
}
} }
#[derive(PartialEq, Eq)] #[derive(PartialEq, Eq)]
enum Message { enum Message {
Item(Box<ChatItem>), User(Box<ParsedMessage>),
System(SharedString), System(SharedString),
Placeholder, Placeholder,
} }
impl Message { impl Message {
pub fn new(chat_message: ChatItem) -> Self { pub fn new(message: ParsedMessage) -> Self {
Self::Item(Box::new(chat_message)) Self::User(Box::new(message))
} }
pub fn system(content: SharedString) -> Self { pub fn system(content: SharedString) -> Self {
@@ -122,7 +138,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(); let this = cx.entity().downgrade();
move |ix, window, cx| { move |ix, window, cx| {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
@@ -285,12 +303,7 @@ impl Chat {
let old_len = self.messages.read(cx).len(); let old_len = self.messages.read(cx).len();
let room = model.read(cx); let room = model.read(cx);
let ago = LastSeen(Timestamp::now()).human_readable(); let message = Message::new(ParsedMessage::new(&room.owner, &content, Timestamp::now()));
let message = Message::new(ChatItem {
profile: room.owner.clone(),
content: content.into(),
ago,
});
// Update message list // Update message list
cx.update_entity(&self.messages, |this, cx| { cx.update_entity(&self.messages, |this, cx| {
@@ -335,11 +348,10 @@ impl Chat {
room.owner.to_owned() room.owner.to_owned()
}; };
Some(Message::new(ChatItem { let message =
profile: member, Message::new(ParsedMessage::new(&member, &ev.content, ev.created_at));
content: ev.content.into(),
ago: LastSeen(ev.created_at).human_readable(), Some(message)
}))
} else { } else {
None None
} }
@@ -377,11 +389,11 @@ impl Chat {
.iter() .iter()
.filter_map(|event| { .filter_map(|event| {
if let Some(profile) = room.member(&event.pubkey) { if let Some(profile) = room.member(&event.pubkey) {
let message = Message::new(ChatItem { let message = Message::new(ParsedMessage::new(
profile, &profile,
content: event.content.clone().into(), &event.content,
ago: LastSeen(event.created_at).human_readable(), event.created_at,
}); ));
if !old_messages.iter().any(|old| old == &message) { if !old_messages.iter().any(|old| old == &message) {
Some(message) Some(message)
@@ -424,7 +436,7 @@ impl Chat {
}; };
// Get message // 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 // Get all attaches and merge its with message
if let Some(attaches) = self.attaches.read(cx).as_ref() { if let Some(attaches) = self.attaches.read(cx).as_ref() {
@@ -434,7 +446,7 @@ impl Chat {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
content = format!("{}\n{}", content, merged) content = format!("{}\n{}", content, merged).into()
} }
if content.is_empty() { if content.is_empty() {
@@ -454,7 +466,7 @@ impl Chat {
let room = model.read(cx); let room = model.read(cx);
let pubkeys = room.pubkeys(); let pubkeys = room.pubkeys();
let async_content = content.clone(); let async_content = content.clone().to_string();
let tags: Vec<Tag> = room let tags: Vec<Tag> = room
.pubkeys() .pubkeys()
.iter() .iter()
@@ -487,7 +499,7 @@ impl Chat {
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
_ = cx.update_window(window_handle, |_, window, cx| { _ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this, cx| { _ = this.update(cx, |this, cx| {
this.push_message(content.clone(), window, cx); this.push_message(content.to_string(), window, cx);
}); });
}); });
@@ -597,7 +609,7 @@ impl Chat {
.w_full() .w_full()
.p_2() .p_2()
.map(|this| match message { .map(|this| match message {
Message::Item(item) => this Message::User(item) => this
.hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE))) .hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE)))
.child( .child(
div() div()
@@ -612,7 +624,7 @@ impl Chat {
}), }),
) )
.child( .child(
img(item.profile.avatar()) img(item.avatar.clone())
.size_8() .size_8()
.rounded_full() .rounded_full()
.flex_shrink_0(), .flex_shrink_0(),
@@ -629,8 +641,10 @@ impl Chat {
.items_baseline() .items_baseline()
.gap_2() .gap_2()
.text_xs() .text_xs()
.child(div().font_semibold().child(item.profile.name())) .child(
.child(div().child(item.ago.clone()).text_color( div().font_semibold().child(item.display_name.clone()),
)
.child(div().child(item.created_at.clone()).text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN), cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)), )),
) )
@@ -667,7 +681,7 @@ impl Chat {
.text_center() .text_center()
.text_xs() .text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.line_height(relative(1.)) .line_height(relative(1.2))
.child( .child(
svg() svg()
.path("brand/coop.svg") .path("brand/coop.svg")

View File

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

View File

@@ -6,7 +6,6 @@ use gpui::{
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use state::get_client; use state::get_client;
use std::{path::PathBuf, time::Duration}; use std::{path::PathBuf, time::Duration};
use tokio::sync::oneshot;
use ui::{ use ui::{
button::{Button, ButtonCustomVariant, ButtonVariants}, button::{Button, ButtonCustomVariant, ButtonVariants},
input::{InputEvent, TextInput}, input::{InputEvent, TextInput},

View File

@@ -9,7 +9,6 @@ use nostr_sdk::prelude::*;
use smol::fs; use smol::fs;
use state::get_client; use state::get_client;
use std::str::FromStr; use std::str::FromStr;
use tokio::sync::oneshot;
use ui::{ use ui::{
button::{Button, ButtonVariants}, button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent}, dock_area::panel::{Panel, PanelEvent},

View File

@@ -4,7 +4,6 @@ use gpui::{
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::get_client; use state::get_client;
use tokio::sync::oneshot;
use ui::{ use ui::{
button::{Button, ButtonVariants}, button::{Button, ButtonVariants},
input::{InputEvent, TextInput}, input::{InputEvent, TextInput},
@@ -66,41 +65,54 @@ impl Relays {
self.set_loading(true, cx); 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 { 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() { if rx.await.is_ok() {
cx.update_window(window_handle, |_, window, cx| { _ = cx.update_window(window_handle, |_, window, cx| {
window.close_modal(cx); _ = this.update(cx, |this, cx| {
this.update(cx, |this, cx| {
this.set_loading(false, cx); this.set_loading(false, cx);
}) });
.unwrap();
}) window.close_modal(cx);
.unwrap(); });
} }
}) })
.detach(); .detach();

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
use anyhow::anyhow; use anyhow::anyhow;
use async_utility::tokio::sync::oneshot;
use common::utils::{compare, room_hash, signer_public_key}; use common::utils::{compare, room_hash, signer_public_key};
use gpui::{App, AppContext, Context, Entity, Global}; use gpui::{App, AppContext, Context, Entity, Global};
use itertools::Itertools; use itertools::Itertools;
@@ -31,8 +30,10 @@ impl ChatRegistry {
pub fn register(cx: &mut App) -> Entity<Self> { pub fn register(cx: &mut App) -> Entity<Self> {
Self::global(cx).unwrap_or_else(|| { Self::global(cx).unwrap_or_else(|| {
let entity = cx.new(Self::new); let entity = cx.new(Self::new);
// Set global state // Set global state
cx.set_global(GlobalChatRegistry(entity.clone())); cx.set_global(GlobalChatRegistry(entity.clone()));
// Observe and load metadata for any new rooms // Observe and load metadata for any new rooms
cx.observe_new::<Room>(|this, _window, cx| { cx.observe_new::<Room>(|this, _window, cx| {
let client = get_client(); let client = get_client();
@@ -74,7 +75,7 @@ impl ChatRegistry {
fn new(_cx: &mut Context<Self>) -> Self { fn new(_cx: &mut Context<Self>) -> Self {
Self { Self {
rooms: Vec::with_capacity(5), rooms: vec![],
is_loading: true, is_loading: true,
} }
} }
@@ -185,6 +186,12 @@ impl ChatRegistry {
}); });
cx.notify(); cx.notify();
}); });
// Re sort rooms by last seen
self.rooms
.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
cx.notify();
} else { } else {
let room = cx.new(|cx| Room::parse(&event, cx)); let room = cx.new(|cx| Room::parse(&event, cx));
self.rooms.insert(0, room); self.rooms.insert(0, room);

View File

@@ -124,6 +124,10 @@ impl Room {
} }
} }
pub fn last_seen(&self) -> &LastSeen {
&self.last_seen
}
/// Get all public keys from current room /// Get all public keys from current room
pub fn pubkeys(&self) -> Vec<PublicKey> { pub fn pubkeys(&self) -> Vec<PublicKey> {
let mut pubkeys: Vec<_> = self.members.iter().map(|m| m.public_key()).collect(); let mut pubkeys: Vec<_> = self.members.iter().map(|m| m.public_key()).collect();

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "common" name = "common"
version = "0.1.0" version = "0.0.0"
edition = "2021" edition = "2021"
publish = false 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_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop"; pub const APP_ID: &str = "su.reya.coop";
pub const FAKE_SIG: &str = "f9e79d141c004977192d05a86f81ec7c585179c371f7350a5412d33575a2a356433f58e405c2296ed273e2fe0aafa25b641e39cc4e1f3f261ebf55bce0cbac83";
/// Subscriptions /// Subscriptions
pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps"; pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps";
pub const ALL_MESSAGES_SUB_ID: &str = "listen_all_giftwraps"; pub const ALL_MESSAGES_SUB_ID: &str = "listen_all_giftwraps";

View File

@@ -2,6 +2,7 @@ use chrono::{Datelike, Local, TimeZone};
use gpui::SharedString; use gpui::SharedString;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct LastSeen(pub Timestamp); pub struct LastSeen(pub Timestamp);
impl LastSeen { impl LastSeen {

View File

@@ -16,13 +16,14 @@ pub async fn signer_public_key(client: &Client) -> anyhow::Result<PublicKey, any
} }
pub async fn preload(client: &Client, public_key: PublicKey) -> anyhow::Result<(), anyhow::Error> { pub async fn preload(client: &Client, public_key: PublicKey) -> anyhow::Result<(), anyhow::Error> {
let sync_opts = SyncOptions::default();
let subscription = Filter::new() let subscription = Filter::new()
.kind(Kind::ContactList) .kind(Kind::ContactList)
.author(public_key) .author(public_key)
.limit(1); .limit(1);
// Get contact list // Get contact list
_ = client.sync(subscription, &SyncOptions::default()).await; _ = client.sync(subscription, &sync_opts).await;
let all_messages_sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID); let all_messages_sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
let new_message_sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); let new_message_sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
use gpui::{ 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, InteractiveElement as _, IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels,
Point, Render, StatefulInteractiveElement, Style, Styled as _, WeakEntity, Window, Point, Render, StatefulInteractiveElement, Style, Styled as _, WeakEntity, Window,
}; };
@@ -374,9 +374,7 @@ impl Render for Dock {
}) })
.map(|this| match &self.panel { .map(|this| match &self.panel {
DockItem::Split { view, .. } => this.child(view.clone()), DockItem::Split { view, .. } => this.child(view.clone()),
DockItem::Tabs { view, .. } => { DockItem::Tabs { view, .. } => this.child(view.clone()),
this.child(AnyView::from(view.clone()).cached(cache_style))
}
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)), DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)),
}) })
.child(self.render_resize_handle(window, cx)) .child(self.render_resize_handle(window, cx))
@@ -432,14 +430,20 @@ impl Element for DockElement {
_: &mut Self::RequestLayoutState, _: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState, _: &mut Self::PrepaintState,
window: &mut gpui::Window, window: &mut gpui::Window,
_: &mut App, cx: &mut App,
) { ) {
window.on_mouse_event({ window.on_mouse_event({
let view = self.view.clone(); let view = self.view.clone();
let is_resizing = view.read(cx).is_resizing;
move |e: &MouseMoveEvent, phase, window, cx| { move |e: &MouseMoveEvent, phase, window, cx| {
if phase.bubble() { if !is_resizing {
view.update(cx, |view, cx| view.resize(e.position, window, cx)) 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.)), .with_easing(cubic_bezier(0.32, 0.72, 0., 1.)),
move |this, delta| { move |this, delta| {
let y_offset = px(0.) + delta * px(30.); 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) this.dismiss(&Dismiss, window, cx)
}), }),
]; ];
let menu = Self { let menu = Self {
focus_handle, focus_handle,
action_focus_handle: None, action_focus_handle: None,
@@ -150,7 +149,7 @@ impl PopupMenu {
scroll_state: Rc::new(Cell::new(ScrollbarState::default())), scroll_state: Rc::new(Cell::new(ScrollbarState::default())),
subscriptions, subscriptions,
}; };
window.refresh();
f(menu, window, cx) f(menu, window, cx)
}) })
} }

View File

@@ -510,41 +510,38 @@ impl Element for ResizePanelGroupElement {
let axis = self.axis; let axis = self.axis;
let current_ix = view.read(cx).resizing_panel_ix; let current_ix = view.read(cx).resizing_panel_ix;
move |e: &MouseMoveEvent, phase, window, cx| { move |e: &MouseMoveEvent, phase, window, cx| {
if phase.bubble() { if !phase.bubble() {
if let Some(ix) = current_ix { 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,
);
}
}
})
}
} }
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 // When any mouse up, stop dragging
window.on_mouse_event({ window.on_mouse_event({
let view = self.view.clone(); let view = self.view.clone();
let current_ix = view.read(cx).resizing_panel_ix;
move |_: &MouseUpEvent, phase, window, cx| { move |_: &MouseUpEvent, phase, window, cx| {
if current_ix.is_none() {
return;
}
if phase.bubble() { if phase.bubble() {
view.update(cx, |view, cx| view.done_resizing(window, cx)); 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 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}; use crate::theme::{scale::ColorScaleStep, ActiveTheme};
@@ -10,18 +18,24 @@ pub enum ScrollbarShow {
#[default] #[default]
Scrolling, Scrolling,
Hover, Hover,
Always,
} }
impl ScrollbarShow { impl ScrollbarShow {
fn is_hover(&self) -> bool { fn is_hover(&self) -> bool {
matches!(self, Self::Hover) matches!(self, Self::Hover)
} }
fn is_always(&self) -> bool {
matches!(self, Self::Always)
}
} }
const BORDER_WIDTH: Pixels = px(0.); const BORDER_WIDTH: Pixels = px(0.);
pub(crate) const WIDTH: Pixels = px(12.);
const MIN_THUMB_SIZE: f32 = 80.; const MIN_THUMB_SIZE: f32 = 80.;
const THUMB_RADIUS: Pixels = Pixels(3.0); const THUMB_RADIUS: Pixels = Pixels(4.0);
const THUMB_INSET: Pixels = Pixels(4.); const THUMB_INSET: Pixels = Pixels(3.);
const FADE_OUT_DURATION: f32 = 3.0; const FADE_OUT_DURATION: f32 = 3.0;
const FADE_OUT_DELAY: f32 = 2.0; const FADE_OUT_DELAY: f32 = 2.0;
@@ -65,6 +79,8 @@ pub struct ScrollbarState {
drag_pos: Point<Pixels>, drag_pos: Point<Pixels>,
last_scroll_offset: Point<Pixels>, last_scroll_offset: Point<Pixels>,
last_scroll_time: Option<Instant>, last_scroll_time: Option<Instant>,
// Last update offset
last_update: Instant,
} }
impl Default for ScrollbarState { impl Default for ScrollbarState {
@@ -76,6 +92,7 @@ impl Default for ScrollbarState {
drag_pos: point(px(0.), px(0.)), drag_pos: point(px(0.), px(0.)),
last_scroll_offset: point(px(0.), px(0.)), last_scroll_offset: point(px(0.), px(0.)),
last_scroll_time: None, last_scroll_time: None,
last_update: Instant::now(),
} }
} }
} }
@@ -106,8 +123,8 @@ impl ScrollbarState {
fn with_hovered(&self, axis: Option<ScrollbarAxis>) -> Self { fn with_hovered(&self, axis: Option<ScrollbarAxis>) -> Self {
let mut state = *self; let mut state = *self;
state.hovered_axis = axis; state.hovered_axis = axis;
if self.is_scrollbar_visible() { if axis.is_some() {
state.last_scroll_time = Some(Instant::now()); state.last_scroll_time = Some(std::time::Instant::now());
} }
state state
} }
@@ -115,6 +132,9 @@ impl ScrollbarState {
fn with_hovered_on_thumb(&self, axis: Option<ScrollbarAxis>) -> Self { fn with_hovered_on_thumb(&self, axis: Option<ScrollbarAxis>) -> Self {
let mut state = *self; let mut state = *self;
state.hovered_on_thumb = axis; state.hovered_on_thumb = axis;
if axis.is_some() {
state.last_scroll_time = Some(std::time::Instant::now());
}
state state
} }
@@ -135,7 +155,18 @@ impl ScrollbarState {
state state
} }
fn with_last_update(&self, t: Instant) -> Self {
let mut state = *self;
state.last_update = t;
state
}
fn is_scrollbar_visible(&self) -> bool { 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 { if let Some(last_time) = self.last_scroll_time {
let elapsed = Instant::now().duration_since(last_time).as_secs_f32(); let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
elapsed < FADE_OUT_DURATION elapsed < FADE_OUT_DURATION
@@ -178,9 +209,9 @@ impl ScrollbarAxis {
match self { match self {
Self::Vertical => vec![Self::Vertical], Self::Vertical => vec![Self::Vertical],
Self::Horizontal => vec![Self::Horizontal], Self::Horizontal => vec![Self::Horizontal],
// This should keep vertical first, vertical is the primary axis // This should keep Horizontal first, Vertical is the primary axis
// if vertical not need display, then horizontal will not keep right margin. // if Vertical not need display, then Horizontal will not keep right margin.
Self::Both => vec![Self::Vertical, Self::Horizontal], Self::Both => vec![Self::Horizontal, Self::Vertical],
} }
} }
} }
@@ -189,11 +220,14 @@ impl ScrollbarAxis {
pub struct Scrollbar { pub struct Scrollbar {
view_id: EntityId, view_id: EntityId,
axis: ScrollbarAxis, axis: ScrollbarAxis,
/// When is vertical, this is the height of the scrollbar.
width: Pixels,
scroll_handle: Rc<Box<dyn ScrollHandleOffsetable>>, scroll_handle: Rc<Box<dyn ScrollHandleOffsetable>>,
scroll_size: gpui::Size<Pixels>, scroll_size: gpui::Size<Pixels>,
state: Rc<Cell<ScrollbarState>>, 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 { impl Scrollbar {
@@ -209,8 +243,8 @@ impl Scrollbar {
state, state,
axis, axis,
scroll_size, scroll_size,
width: px(12.),
scroll_handle: Rc::new(Box::new(scroll_handle)), scroll_handle: Rc::new(Box::new(scroll_handle)),
max_fps: 120,
} }
} }
@@ -290,11 +324,21 @@ impl Scrollbar {
self 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) { fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels) {
( (
cx.theme().scrollbar_thumb_hover, cx.theme().scrollbar_thumb_hover,
cx.theme().scrollbar, cx.theme().scrollbar,
cx.theme().base.step(cx, ColorScaleStep::THREE), cx.theme().base.step(cx, ColorScaleStep::SEVEN),
THUMB_INSET - px(1.), THUMB_INSET - px(1.),
THUMB_RADIUS, THUMB_RADIUS,
) )
@@ -304,7 +348,7 @@ impl Scrollbar {
( (
cx.theme().scrollbar_thumb_hover, cx.theme().scrollbar_thumb_hover,
cx.theme().scrollbar, cx.theme().scrollbar,
cx.theme().base.step(cx, ColorScaleStep::THREE), cx.theme().base.step(cx, ColorScaleStep::SIX),
THUMB_INSET - px(1.), THUMB_INSET - px(1.),
THUMB_RADIUS, THUMB_RADIUS,
) )
@@ -382,11 +426,11 @@ impl Element for Scrollbar {
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) { ) -> (gpui::LayoutId, Self::RequestLayoutState) {
let style = Style { let style = gpui::Style {
position: Position::Absolute, position: Position::Absolute,
flex_grow: 1.0, flex_grow: 1.0,
flex_shrink: 1.0, flex_shrink: 1.0,
size: Size { size: gpui::Size {
width: relative(1.).into(), width: relative(1.).into(),
height: relative(1.).into(), height: relative(1.).into(),
}, },
@@ -409,7 +453,6 @@ impl Element for Scrollbar {
}); });
let mut states = vec![]; let mut states = vec![];
let mut has_both = self.axis.is_both(); let mut has_both = self.axis.is_both();
for axis in self.axis.all().into_iter() { 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. // 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 { let margin_end = if has_both && !is_vertical {
self.width WIDTH
} else { } else {
px(0.) px(0.)
}; };
@@ -449,31 +492,29 @@ impl Element for Scrollbar {
let bounds = Bounds { let bounds = Bounds {
origin: if is_vertical { origin: if is_vertical {
point( point(hitbox.origin.x + hitbox.size.width - WIDTH, hitbox.origin.y)
hitbox.origin.x + hitbox.size.width - self.width,
hitbox.origin.y,
)
} else { } else {
point( point(
hitbox.origin.x, hitbox.origin.x,
hitbox.origin.y + hitbox.size.height - self.width, hitbox.origin.y + hitbox.size.height - WIDTH,
) )
}, },
size: gpui::Size { size: gpui::Size {
width: if is_vertical { width: if is_vertical {
self.width WIDTH
} else { } else {
hitbox.size.width hitbox.size.width
}, },
height: if is_vertical { height: if is_vertical {
hitbox.size.height hitbox.size.height
} else { } else {
self.width WIDTH
}, },
}, },
}; };
let state = self.state.clone(); 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_hover_to_show = cx.theme().scrollbar_show.is_hover();
let is_hovered_on_bar = state.get().hovered_axis == Some(axis); let is_hovered_on_bar = state.get().hovered_axis == Some(axis);
let is_hovered_on_thumb = state.get().hovered_on_thumb == 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) = let (thumb_bg, bar_bg, bar_border, inset, radius) =
if state.get().dragged_axis == Some(axis) { if state.get().dragged_axis == Some(axis) {
Self::style_for_active(cx) 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 { if is_hovered_on_thumb {
Self::style_for_hovered_thumb(cx) Self::style_for_hovered_thumb(cx)
} else { } else {
@@ -520,12 +563,12 @@ impl Element for Scrollbar {
let thumb_bounds = if is_vertical { let thumb_bounds = if is_vertical {
Bounds::from_corners( Bounds::from_corners(
point(bounds.origin.x, bounds.origin.y + thumb_start), 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 { } else {
Bounds::from_corners( Bounds::from_corners(
point(bounds.origin.x + thumb_start, bounds.origin.y), 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 { let thumb_fill_bounds = if is_vertical {
@@ -535,7 +578,7 @@ impl Element for Scrollbar {
bounds.origin.y + thumb_start + inset, bounds.origin.y + thumb_start + inset,
), ),
point( point(
bounds.origin.x + self.width - inset, bounds.origin.x + WIDTH - inset,
bounds.origin.y + thumb_end - inset, bounds.origin.y + thumb_end - inset,
), ),
) )
@@ -547,7 +590,7 @@ impl Element for Scrollbar {
), ),
point( point(
bounds.origin.x + thumb_end - inset, 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_visible = self.state.get().is_scrollbar_visible();
let is_hover_to_show = cx.theme().scrollbar_show.is_hover(); 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( window.with_content_mask(
Some(ContentMask { Some(ContentMask {
bounds: hitbox_bounds, bounds: hitbox_bounds,
@@ -711,30 +763,36 @@ impl Element for Scrollbar {
let scroll_handle = self.scroll_handle.clone(); let scroll_handle = self.scroll_handle.clone();
let state = self.state.clone(); let state = self.state.clone();
let view_id = self.view_id; let view_id = self.view_id;
let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64);
move |event: &MouseMoveEvent, _, _, cx| { 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 // 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) { if state.get().hovered_axis != Some(axis) {
state.set(state.get().with_hovered(Some(axis))); notify = true;
cx.notify(view_id);
} }
} else if state.get().hovered_axis == Some(axis) } else if state.get().hovered_axis == Some(axis)
&& state.get().hovered_axis.is_some() && state.get().hovered_axis.is_some()
{ {
state.set(state.get().with_hovered(None)); state.set(state.get().with_hovered(None));
cx.notify(view_id); notify = true;
} }
// Update hovered state for scrollbar thumb // Update hovered state for scrollbar thumb
if thumb_bounds.contains(&event.position) { if thumb_bounds.contains(&event.position) {
if state.get().hovered_on_thumb != Some(axis) { if state.get().hovered_on_thumb != Some(axis) {
state.set(state.get().with_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) { } else if state.get().hovered_on_thumb == Some(axis) {
state.set(state.get().with_hovered_on_thumb(None)); state.set(state.get().with_hovered_on_thumb(None));
cx.notify(view_id); notify = true;
} }
// Move thumb position on dragging // Move thumb position on dragging
@@ -769,10 +827,18 @@ impl Element for Scrollbar {
if (scroll_handle.offset().y - offset.y).abs() > px(1.) if (scroll_handle.offset().y - offset.y).abs() > px(1.)
|| (scroll_handle.offset().x - offset.x).abs() > px(1.) || (scroll_handle.offset().x - offset.x).abs() > px(1.)
{ {
scroll_handle.set_offset(offset); // Limit update rate
cx.notify(view_id); 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.bottom, |div| div.pb(SHADOW_SIZE))
.when(!tiling.left, |div| div.pl(SHADOW_SIZE)) .when(!tiling.left, |div| div.pl(SHADOW_SIZE))
.when(!tiling.right, |div| div.pr(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| { .on_mouse_down(MouseButton::Left, move |_, window, _cx| {
let size = window.window_bounds().get_bounds().size; let size = window.window_bounds().get_bounds().size;
let pos = window.mouse_position(); let pos = window.mouse_position();