Compare commits
2
Commits
f9e538257a
...
91c40b0799
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91c40b0799 | ||
|
|
1b6ef6f574 |
Generated
+2
@@ -1429,6 +1429,7 @@ dependencies = [
|
||||
"auto_update",
|
||||
"chat",
|
||||
"common",
|
||||
"community",
|
||||
"device",
|
||||
"gpui-pre",
|
||||
"gpui-pre-linux",
|
||||
@@ -1454,6 +1455,7 @@ dependencies = [
|
||||
"assets",
|
||||
"chat",
|
||||
"common",
|
||||
"community",
|
||||
"console_error_panic_hook",
|
||||
"console_log",
|
||||
"device",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
||||
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
|
||||
use instant::Duration;
|
||||
|
||||
@@ -35,7 +35,7 @@ fn uses_managed_updates() -> bool {
|
||||
}
|
||||
|
||||
/// Initialize the auto-update system.
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
pub fn init(cx: &mut App) {
|
||||
if uses_managed_updates() {
|
||||
log::info!(
|
||||
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
|
||||
@@ -60,10 +60,7 @@ pub fn init(window: &mut Window, cx: &mut App) {
|
||||
return;
|
||||
};
|
||||
|
||||
AutoUpdater::set_global(
|
||||
cx.new(|cx| AutoUpdater::new(window, version, filter, cx)),
|
||||
cx,
|
||||
);
|
||||
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(version, filter, cx)), cx);
|
||||
}
|
||||
|
||||
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||
@@ -103,21 +100,17 @@ impl AutoUpdater {
|
||||
cx.set_global(GlobalAutoUpdater(state));
|
||||
}
|
||||
|
||||
fn new(
|
||||
window: &mut Window,
|
||||
version: Version,
|
||||
filter: AssetFilter,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
fn new(version: Version, filter: AssetFilter, cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
|
||||
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
|
||||
let engine = Arc::new(UpdateEngine::new(source, config));
|
||||
|
||||
// Schedule an auto-check after a 2-minute delay
|
||||
cx.defer_in(window, |_this, _window, cx| {
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.defer(move |cx| {
|
||||
cx.spawn(async move |cx| {
|
||||
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
||||
this.update(cx, |this, cx| this.check(cx)).ok();
|
||||
entity.update(cx, |this, cx| this.check(cx)).ok();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
+24
-6
@@ -26,8 +26,8 @@ pub use state::FileAttachment;
|
||||
/// A static keypair used only for signing locally-cached rumor events.
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx);
|
||||
pub fn init(cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||
@@ -150,7 +150,8 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Create a new chat registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let (tx, rx) = flume::unbounded::<Signal>();
|
||||
let mut subscriptions = smallvec![];
|
||||
@@ -167,9 +168,12 @@ impl ChatRegistry {
|
||||
}),
|
||||
);
|
||||
|
||||
// Run at the end of the current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
cx.defer(move |cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -221,7 +225,21 @@ impl ChatRegistry {
|
||||
};
|
||||
|
||||
match *message {
|
||||
RelayMessage::Event { event, .. } => {
|
||||
RelayMessage::Event {
|
||||
subscription_id,
|
||||
event,
|
||||
..
|
||||
} => {
|
||||
let chat_sub = subscription_id.as_str() != sub_id1.as_str();
|
||||
let device_sub = subscription_id.as_str() != sub_id2.as_str();
|
||||
|
||||
// Concord wraps are also kind 1059.
|
||||
//
|
||||
// Only the two gift wrap subscriptions carry NIP-59 wraps for this account.
|
||||
if event.kind == Kind::GiftWrap && chat_sub && device_sub {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prune the dedup set before it grows unbounded
|
||||
if processed_events.len() >= MAX_PROCESSED {
|
||||
processed_events.clear();
|
||||
|
||||
@@ -4,7 +4,7 @@ use anyhow::Result;
|
||||
use concord::CommunityId;
|
||||
use concord::cord01::KIND_WRAP;
|
||||
use concord::store::CommunityState;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
@@ -15,8 +15,8 @@ mod sync;
|
||||
pub use community::*;
|
||||
pub use sync::*;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
CommunityRegistry::set_global(cx.new(|cx| CommunityRegistry::new(window, cx)), cx);
|
||||
pub fn init(cx: &mut App) {
|
||||
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalCommunityRegistry(Entity<CommunityRegistry>);
|
||||
@@ -56,7 +56,8 @@ impl CommunityRegistry {
|
||||
cx.set_global(GlobalCommunityRegistry(state));
|
||||
}
|
||||
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let (tx, rx) = flume::bounded::<Signal>(256);
|
||||
let mut subscriptions = smallvec![];
|
||||
@@ -69,12 +70,15 @@ impl CommunityRegistry {
|
||||
}
|
||||
}));
|
||||
|
||||
cx.defer_in(window, move |this, _window, cx| {
|
||||
cx.defer(move |cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.handle_notifications(cx);
|
||||
|
||||
if nostr.read(cx).current_user().is_some() {
|
||||
this.load(cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
|
||||
+25
-11
@@ -24,8 +24,8 @@ use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
const IDENTIFIER: &str = "coop:device";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
|
||||
pub fn init(cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
|
||||
@@ -89,7 +89,8 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// Create a new device registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let settings = AppSettings::global(cx);
|
||||
|
||||
@@ -114,8 +115,10 @@ impl DeviceRegistry {
|
||||
}),
|
||||
);
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.handle_notifications(window, cx);
|
||||
cx.defer(move |cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| this.handle_notifications(cx))
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -127,7 +130,7 @@ impl DeviceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
@@ -168,18 +171,18 @@ impl DeviceRegistry {
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
match event.kind {
|
||||
Kind::Custom(10044) => {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_encryption(&event, cx);
|
||||
})?;
|
||||
}
|
||||
// New request event from other device
|
||||
Kind::Custom(4454) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.ask_for_approval(event, window, cx);
|
||||
this.update(cx, |this, cx| {
|
||||
this.ask_for_approval(event, cx);
|
||||
})?;
|
||||
}
|
||||
// New response event from the master device
|
||||
@@ -591,7 +594,7 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// Handle encryption request
|
||||
fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
// Ignore if there is already a pending request
|
||||
if self.pending_request {
|
||||
return;
|
||||
@@ -600,7 +603,18 @@ impl DeviceRegistry {
|
||||
|
||||
// Show notification
|
||||
let notification = self.notification(event, cx);
|
||||
|
||||
// The registry is global and not bound to a window, so surface the
|
||||
// request in an open window.
|
||||
if let Some(window) = cx.windows().first().copied() {
|
||||
if let Err(error) = window.update(cx, |_view, window, cx| {
|
||||
window.push_notification(notification, cx);
|
||||
}) {
|
||||
log::warn!("Failed to show encryption key request: {error}");
|
||||
}
|
||||
} else {
|
||||
log::warn!("Failed to show encryption key request: no open window");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a notification for the encryption request.
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::RwLock;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventExt;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
@@ -13,8 +13,8 @@ mod person;
|
||||
|
||||
pub use person::*;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx);
|
||||
pub fn init(cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalPersonRegistry(Entity<PersonRegistry>);
|
||||
@@ -56,7 +56,8 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
/// Create a new person registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
@@ -96,8 +97,8 @@ impl PersonRegistry {
|
||||
}));
|
||||
|
||||
// Load all user profiles from the database
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.load(cx);
|
||||
cx.defer(move |cx| {
|
||||
entity.update(cx, |this, cx| this.load(cx)).ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
|
||||
+17
-15
@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::{Theme, ThemeFamily, ThemeMode};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
|
||||
pub fn init(cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||
@@ -195,7 +195,8 @@ impl AppSettings {
|
||||
cx.set_global(GlobalAppSettings(state));
|
||||
}
|
||||
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let inner = cx.new(|_| Settings::default());
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
@@ -207,8 +208,8 @@ impl AppSettings {
|
||||
);
|
||||
|
||||
// Run at the end of current cycle
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load(window, cx);
|
||||
cx.defer(move |cx| {
|
||||
entity.update(cx, |this, cx| this.load(cx)).ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -226,7 +227,7 @@ impl AppSettings {
|
||||
}
|
||||
|
||||
/// Load settings
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -238,7 +239,7 @@ impl AppSettings {
|
||||
Err(anyhow!("Not found"))
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let mut settings = task.await.unwrap_or(Settings::default());
|
||||
|
||||
// Move settings still pointed at the old default file server over to the new one
|
||||
@@ -247,9 +248,10 @@ impl AppSettings {
|
||||
}
|
||||
|
||||
// Update settings
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_settings(settings, cx);
|
||||
this.apply_theme(window, cx);
|
||||
this.apply_theme(None, cx);
|
||||
cx.refresh_windows();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
@@ -281,7 +283,7 @@ impl AppSettings {
|
||||
});
|
||||
|
||||
// Apply the new theme
|
||||
self.apply_theme(window, cx);
|
||||
self.apply_theme(Some(window), cx);
|
||||
}
|
||||
|
||||
/// Reset theme
|
||||
@@ -290,22 +292,22 @@ impl AppSettings {
|
||||
this.theme = None;
|
||||
cx.notify();
|
||||
});
|
||||
self.apply_theme(window, cx);
|
||||
self.apply_theme(Some(window), cx);
|
||||
}
|
||||
|
||||
/// Apply theme
|
||||
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
pub fn apply_theme(&mut self, mut window: Option<&mut Window>, cx: &mut Context<Self>) {
|
||||
if let Some(name) = self.inner.read(cx).theme.as_ref() {
|
||||
let mode = self.inner.read(cx).theme_mode;
|
||||
|
||||
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
|
||||
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
|
||||
Theme::change(mode, Some(window), cx);
|
||||
Theme::apply_theme(Rc::new(new_theme), window.as_deref_mut(), cx);
|
||||
Theme::change(mode, window, cx);
|
||||
} else {
|
||||
log::info!("Failed to load theme: {name}");
|
||||
}
|
||||
} else {
|
||||
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx);
|
||||
Theme::apply_theme(Rc::new(ThemeFamily::default()), window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-6
@@ -4,7 +4,7 @@ use anyhow::{Error, anyhow};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use browser_signer_proxy::prelude::*;
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
||||
use gpui_tokio::Tokio;
|
||||
use instant::Duration;
|
||||
use nostr_connect::prelude::*;
|
||||
@@ -29,7 +29,7 @@ pub use nip4e::*;
|
||||
pub use nip05::*;
|
||||
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
|
||||
pub fn init(cx: &mut App, cli_key: Option<SecretKey>) {
|
||||
// rustls uses the `aws_lc_rs` provider by default
|
||||
// This only errors if the default provider has already
|
||||
// been installed. We can ignore this `Result`.
|
||||
@@ -42,7 +42,7 @@ pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
gpui_tokio::init(cx);
|
||||
|
||||
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx, cli_key)), cx);
|
||||
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(cx, cli_key)), cx);
|
||||
}
|
||||
|
||||
struct GlobalNostrRegistry(Entity<NostrRegistry>);
|
||||
@@ -105,7 +105,8 @@ impl NostrRegistry {
|
||||
}
|
||||
|
||||
/// Create a new nostr instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
|
||||
fn new(cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let signer = UniversalSigner::new(Keys::generate());
|
||||
let authenticator = SignerAuthenticator::new(signer.clone());
|
||||
|
||||
@@ -132,8 +133,10 @@ impl NostrRegistry {
|
||||
})
|
||||
.build();
|
||||
|
||||
// Connect to bootstrap relays after the window is ready
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
// Connect to bootstrap relays once the registry has been returned to the app
|
||||
cx.defer(move |cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.connect_bootstrap_relays(cx);
|
||||
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
@@ -145,6 +148,8 @@ impl NostrRegistry {
|
||||
} else {
|
||||
this.get_user_credential(cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
|
||||
@@ -35,6 +35,7 @@ common = { path = "../crates/common" }
|
||||
state = { path = "../crates/state" }
|
||||
device = { path = "../crates/device" }
|
||||
chat = { path = "../crates/chat" }
|
||||
community = { path = "../crates/community" }
|
||||
settings = { path = "../crates/settings" }
|
||||
auto_update = { path = "../crates/auto_update" }
|
||||
person = { path = "../crates/person" }
|
||||
|
||||
+29
-29
@@ -31,6 +31,12 @@ fn main() {
|
||||
.with_assets(Assets)
|
||||
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()))
|
||||
.run(move |cx| {
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
// Initialize theme registry
|
||||
theme::init(cx);
|
||||
|
||||
// Load embedded fonts in assets/fonts
|
||||
load_embedded_fonts(cx);
|
||||
|
||||
@@ -55,6 +61,29 @@ fn main() {
|
||||
disabled: false,
|
||||
}]);
|
||||
|
||||
// Initialize settings
|
||||
settings::init(cx);
|
||||
|
||||
// Initialize the nostr client
|
||||
state::init(cx, cli_key);
|
||||
|
||||
// Initialize person registry
|
||||
person::init(cx);
|
||||
|
||||
// Initialize device signer
|
||||
//
|
||||
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
device::init(cx);
|
||||
|
||||
// Initialize app registry
|
||||
chat::init(cx);
|
||||
|
||||
// Initialize community registry
|
||||
community::init(cx);
|
||||
|
||||
// Initialize auto update
|
||||
auto_update::init(cx);
|
||||
|
||||
// Set up the window bounds
|
||||
let bounds = Bounds::centered(None, size(px(960.0), px(720.0)), cx);
|
||||
|
||||
@@ -77,40 +106,11 @@ fn main() {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Open a window with default options
|
||||
cx.open_window(opts, |window, cx| {
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
// Initialize theme registry
|
||||
theme::init(cx);
|
||||
|
||||
// Initialize settings
|
||||
settings::init(window, cx);
|
||||
|
||||
// Initialize the nostr client
|
||||
state::init(window, cx, cli_key);
|
||||
|
||||
// Initialize person registry
|
||||
person::init(window, cx);
|
||||
|
||||
// Initialize device signer
|
||||
//
|
||||
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
device::init(window, cx);
|
||||
|
||||
// Initialize app registry
|
||||
chat::init(window, cx);
|
||||
|
||||
// Initialize auto update
|
||||
auto_update::init(window, cx);
|
||||
|
||||
// Root view
|
||||
cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx))
|
||||
})
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
# The community registry — implementation plan
|
||||
|
||||
`crates/community` is the GPUI layer over `crates/concord`: a global registry, one
|
||||
entity per community, and one notification stream that keeps both fed from the
|
||||
local database. `crates/concord` stays GPUI-free; this crate is the only place
|
||||
where entities, tasks and subscriptions meet.
|
||||
|
||||
The shape follows `crates/chat`: a registry global created in `init`, entities
|
||||
that own protocol state, a background notification listener, and a foreground
|
||||
consumer that is the only writer of entity state.
|
||||
|
||||
## Scope
|
||||
|
||||
In:
|
||||
|
||||
- discovery of the current account's communities from the local database;
|
||||
- one `Entity<Community>` owning `CommunityState`, the last `ControlFold`, the
|
||||
folded member list and the channel list;
|
||||
- subscriptions to every held plane (Control, Guestbook, public Channels);
|
||||
- notification-driven refresh: open and cache wraps in the background, fold them
|
||||
there too, and apply only the result on the foreground;
|
||||
- re-subscription when a fold moves a plane address or a relay list changes.
|
||||
|
||||
Out (see "Known gaps"): rekeys, private-channel keys, sends, moderation,
|
||||
invites, chat timelines, and NIP-46 writers.
|
||||
|
||||
## Crate layout
|
||||
|
||||
| File | Owns |
|
||||
| --- | --- |
|
||||
| `community.rs` | The `Community` entity, `CommunityEvent`, refresh coalescing |
|
||||
| `lib.rs` | `init`, `CommunityRegistry`, the signal channel, subscription sync |
|
||||
| `sync.rs` | Planes, the REQ filter, loading from the database, the fold |
|
||||
|
||||
## Entities
|
||||
|
||||
`CommunityRegistry` mirrors `ChatRegistry`:
|
||||
|
||||
```rust
|
||||
pub struct CommunityRegistry {
|
||||
communities: Vec<Entity<Community>>,
|
||||
index: HashMap<CommunityId, Entity<Community>>,
|
||||
synced: HashMap<CommunityId, SubscriptionKey>,
|
||||
signal_tx: flume::Sender<Signal>,
|
||||
signal_rx: flume::Receiver<Signal>,
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
|
||||
notification_listener: Option<Task<Result<(), Error>>>,
|
||||
signal_consumer: Option<Task<Result<(), Error>>>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
pub enum CommunityEvent {
|
||||
Updated(CommunityId),
|
||||
Error(String),
|
||||
}
|
||||
```
|
||||
|
||||
- `Updated` is emitted by a `Community` after a fold is applied; the registry
|
||||
emits only `Error`. Views subscribe where they render.
|
||||
- `Community` holds `state: CommunityState`, `control: ControlFold`,
|
||||
`members: BTreeSet<PublicKey>`, a `dirty` flag and one in-flight
|
||||
`refresh_task`. Public reads: `id()`, `state()`, `control()`, `members()`,
|
||||
`channels()`.
|
||||
- The registry is not an emitter of `Updated`: a view holds
|
||||
`Entity<Community>` and observes that.
|
||||
|
||||
## Loading
|
||||
|
||||
The registry loads when the signer changes and once at startup through
|
||||
`cx.defer_in`.
|
||||
|
||||
- State documents are discovered by scanning the local database for
|
||||
`Kind::ApplicationSpecificData` events whose `d` tag is `concord/<hex>`,
|
||||
newest per community. This is the bootstrap path: `cord02::genesis` +
|
||||
`store::save_state` (the creation flow in `concord-usage.md`) writes no list
|
||||
entry, so a list-only load would show nothing until joining exists.
|
||||
- The Community List (`kind 13302`, NIP-44 to self) is read from the database
|
||||
without `fetch_events`; decryption goes through `UniversalSigner::nip44_decrypt_async`,
|
||||
so NIP-46 signers work. When a list exists it is authoritative for liveness:
|
||||
a state document whose id is not live (no entry, or a newer tombstone) is
|
||||
dropped. With no list event, every state document loads.
|
||||
- `cord02::list::parse_list_event` takes `&Keys`, which the UI layer does not
|
||||
hold, so the list is decrypted with the signer and parsed as
|
||||
`CommunityList` directly. Nothing in the list is rewritten here.
|
||||
|
||||
Creation and join flows persist a `CommunityState` themselves and call
|
||||
`CommunityRegistry::reload`; the registry grows no writer APIs it cannot
|
||||
correctly support.
|
||||
|
||||
## Subscriptions
|
||||
|
||||
A community's planes are derived from its state; the wrap's *author* is the
|
||||
routing key.
|
||||
|
||||
| Plane | Read key | Wrap author (`Filter::authors`) |
|
||||
| --- | --- | --- |
|
||||
| Control, each held epoch | `control_group_key(root, id, epoch)` | `state.control_pks[epoch]` (the signer pk) |
|
||||
| Guestbook | `guestbook_group_key(root, id, root_epoch)` | the group's own pk |
|
||||
| Channel (public only) | `channel_group_key(root, channel, channel.epoch)` | the group's own pk |
|
||||
|
||||
One caveat on the snippet in `concord-usage.md`: it uses
|
||||
`Filter::new().pubkey(plane.pk())`, but in this nostr-sdk `Filter::pubkey` adds a
|
||||
`#p` tag constraint, and a Concord wrap's `p` tag is a random ephemeral key
|
||||
(`cord01::wrap_seal_with`). The filter must be `.authors(...)`, matching the
|
||||
event author that `open_wrap_at` already checks.
|
||||
|
||||
- One subscription id per community: `SubscriptionId::new("concord/<hex>")`.
|
||||
Routing back from a notification is a prefix strip and a hex parse.
|
||||
- `Community` exposes a cheap `SubscriptionKey` (control pks, channels + epochs
|
||||
+ privacy, relays). When a fold changes it, the registry re-subscribes:
|
||||
`unsubscribe` then `subscribe` with the same id.
|
||||
- Community relays are added to the client explicitly
|
||||
(`client.add_relay(..).and_connect()`), per `concord-usage.md`.
|
||||
|
||||
## Notification stream
|
||||
|
||||
`client.notifications()` is one stream for the whole app; the community listener
|
||||
takes the first-seen variant and routes by subscription id, never by kind:
|
||||
|
||||
```rust
|
||||
while let Some(notification) = notifications.next().await {
|
||||
let ClientNotification::Event { subscription_id, event, .. } = notification else {
|
||||
continue;
|
||||
};
|
||||
if event.kind != Kind::from(KIND_WRAP) {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = sync::community_of(&subscription_id) else {
|
||||
continue;
|
||||
};
|
||||
tx.send_async(Signal::Event(id)).await?;
|
||||
}
|
||||
```
|
||||
|
||||
- `ClientNotification::Event` fires only the first time an event is seen; the
|
||||
relay has already saved it to the local database before notifying
|
||||
(`nostr-sdk` relay inner), so a signal only needs the community id and the
|
||||
fold reads the wrap back from the database. This is also what makes restart
|
||||
work: a backlog already in the database produces no notification, so
|
||||
`Community::refresh` runs once when the community is tracked.
|
||||
- `KIND_WRAP_EPHEMERAL` (21059, typing) is not subscribed: ephemeral events are
|
||||
never persisted, so the database-read path cannot see them. Nothing in the
|
||||
registry consumes typing today.
|
||||
- The channel is `flume::bounded(256)`; the consumer is a foreground `cx.spawn`
|
||||
that updates entities, as in `concord-usage.md`.
|
||||
|
||||
The chat registry must route gift wraps by subscription id before community
|
||||
subscriptions go live, or every stream wrap lands in the DM trash:
|
||||
|
||||
```rust
|
||||
RelayMessage::Event { subscription_id, event } => {
|
||||
if event.kind == Kind::GiftWrap
|
||||
&& subscription_id.as_ref() != sub_id1.as_str()
|
||||
&& subscription_id.as_ref() != sub_id2.as_str()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// ..
|
||||
}
|
||||
```
|
||||
|
||||
The `InboxRelays` handling in the same loop stays unscoped: it arrives on a
|
||||
short-lived subscription with a generated id.
|
||||
|
||||
## The fold
|
||||
|
||||
One background function, `sync::fold(database, state) -> Snapshot`, does all
|
||||
crypto, verification, I/O and folding. `Snapshot` carries the applied
|
||||
`CommunityState`, the `ControlFold` and the member set; the foreground only
|
||||
assigns.
|
||||
|
||||
1. Derive the held planes.
|
||||
2. For each plane, query the database for `KIND_WRAP` events authored by the
|
||||
plane address and open them:
|
||||
- Control: `cord02::open_edition(wrap, read, address, true)` → `ParsedEdition`;
|
||||
- Guestbook: `cord02::guestbook::open(wrap, group)` → `GuestbookRumor`;
|
||||
- Channel: `cord03::open(wrap, group, channel, epoch)` then
|
||||
`store::cache_rumor` — the chat read path is already database-backed.
|
||||
Collect `observed: PublicKey -> ms` from every author that opened.
|
||||
3. `cord02::fold_control(owner, id, &editions, &state.floors(), &state.banned)`,
|
||||
then `state.apply_fold` and `store::save_state`, all in this task. If no
|
||||
edition opened at all, the fold is not applied: an empty fold would erase the
|
||||
committed floors the next fold is judged against.
|
||||
4. `cord02::guestbook::coalesce` with the roster-backed `can_kick`
|
||||
(`citation_ok` + `can_act_on_member(.., Permissions::KICK)`), then
|
||||
`complete_memberlist` with `observed`, the roster's grants, `control.banned`
|
||||
and an empty `banned_at`. The owner is inserted explicitly — the roster does
|
||||
not mint an implicit grant for them.
|
||||
|
||||
## Foreground and background
|
||||
|
||||
- Every entity touch and every fold application happens on the foreground.
|
||||
Background tasks only read the database and return values.
|
||||
- `Community::refresh` coalesces: if a fold is in flight it sets `dirty`, and
|
||||
the completion applies the snapshot, clears the task, then runs one more fold
|
||||
if dirtied. A burst of backlog events produces at most two folds.
|
||||
- `refresh_task: Option<Task<_>>` is dropped on reset, which cancels it.
|
||||
- The registry observes each community (`cx.observe`) and re-syncs
|
||||
subscriptions when a fold changed a plane or relay set; sync is a key
|
||||
comparison, so ordinary notifies are a no-op.
|
||||
- Errors from load/subscribe/fold reach the UI as `CommunityEvent::Error`; a
|
||||
task whose result is never read must not be the only error path.
|
||||
|
||||
## Integration
|
||||
|
||||
- `community::init(window, cx)` in `desktop/src/main.rs` and `web/src/lib.rs`
|
||||
after `chat::init`.
|
||||
- Chat's notification routing fix above.
|
||||
- `concord::store::{save_state, load_state}` gain `+ ?Sized` on `D`: the
|
||||
integration path passes `&dyn NostrDatabase` (the doc's advice cannot compile
|
||||
against the current bound). `cache_rumor`, `query_rumors`, `purge_expired`
|
||||
and `backfill` already take `&dyn`.
|
||||
|
||||
## Tests
|
||||
|
||||
Pure `#[test]` with `MemoryDatabase` and `smol::block_on`, like `concord`'s
|
||||
store tests; no GPUI test context (no registry test exists in this repo, and
|
||||
`state::init` owns the global client).
|
||||
|
||||
1. genesis folds into metadata, the general channel, and an owner-only member
|
||||
list, and the folded state is persisted.
|
||||
2. a member's join becomes a member and a later leave removes them.
|
||||
3. the subscription filter asks for every held plane by wrap author, and
|
||||
`community_of(subscription_id(id)) == Some(id)`.
|
||||
4. loading: state documents load without a list; a Community List entry keeps a
|
||||
community and a newer tombstone hides it.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **Rekeys are not adopted.** `CommunityState` cannot hold a second root or a
|
||||
channel key, so the rekey planes (one epoch ahead) are not subscribed. A
|
||||
community stays on the plane set its state can derive.
|
||||
- **Private channels are skipped**, not guessed: no key is held for them yet.
|
||||
- The fold re-opens every wrap on every refresh. `ClientNotification::Event`
|
||||
plus refresh coalescing keep it bounded, and the database read path stays
|
||||
simple; incremental caches are a follow-up.
|
||||
- `list.is_live` filtering is only as fresh as the last list event; the
|
||||
registry never writes list or state documents for the user's account.
|
||||
|
||||
## Phases
|
||||
|
||||
Phases are sequential: each one lands compiling code and has an exit check. Nothing
|
||||
in a later phase is started before the earlier one is green, so the crate is never
|
||||
in a half-wired state.
|
||||
|
||||
### Phase 0 — Decisions
|
||||
|
||||
Five calls to confirm before writing code. Defaults in brackets.
|
||||
|
||||
1. **v1 planes** [Control + Guestbook + public Channels]. Rekeys and private
|
||||
channels are out of scope, not stubs.
|
||||
2. **Discovery and liveness** [scan the local DB for `concord/<hex>` state
|
||||
documents; read the Community List when present and use `is_live` to drop
|
||||
tombstones; never republish the list].
|
||||
3. **Wiring** [`community::init` after `chat::init` in `desktop` and `web`].
|
||||
4. **Chat routing fix** [route kind 1059 by subscription id in
|
||||
`chat::handle_notifications`; leave `InboxRelays` unscoped].
|
||||
5. **`concord::store` bound** [add `+ ?Sized` to `save_state`/`load_state` so
|
||||
`&dyn NostrDatabase` compiles; the doc's snippet does not compile today].
|
||||
6. **Tests** [pure `#[test]` + `smol::block_on` + `MemoryDatabase`; no GPUI test
|
||||
context].
|
||||
|
||||
Exit: all six confirmed. Any that change rewrite the affected phase below.
|
||||
|
||||
### Phase 1 — Crate skeleton
|
||||
|
||||
- Create `crates/community/Cargo.toml` and `src/{lib,community,sync}.rs` stubs.
|
||||
- Deps: `concord`, `state`, `gpui`, `nostr-sdk`, `anyhow`, `flume`, `log`,
|
||||
`serde_json`, `smallvec`. Dev: `nostr-memory`, `smol`.
|
||||
- The workspace already globs `crates/*`, so no root manifest edit.
|
||||
|
||||
Exit: `cargo check -p community` passes with the empty modules.
|
||||
|
||||
### Phase 2 — `sync.rs`
|
||||
|
||||
Pure, GPUI-free plumbing: `Plane`/`PlaneKind`, `planes(&CommunityState)`,
|
||||
`subscription_filter` (using `.authors(...)`, not `.pubkey(...)`), the
|
||||
`SubscriptionId`/`community_of` round-trip, `load`, and `fold -> Snapshot`.
|
||||
|
||||
Exit: unit tests 3 and 4 pass; no GPUI types in the file.
|
||||
|
||||
### Phase 3 — `community.rs`
|
||||
|
||||
`Community` entity (`state`, `control`, `members`, `dirty`, in-flight
|
||||
`refresh_task`), `CommunityEvent::{Updated, Error}`, and coalesced refresh that
|
||||
spawns the fold on `background_spawn` and applies the snapshot on the foreground.
|
||||
|
||||
Exit: tests 1 and 2 pass; entity compiles against a `TestAppContext`-free test.
|
||||
|
||||
### Phase 4 — `lib.rs`
|
||||
|
||||
`init` plus `CommunityRegistry`: bounded `flume(256)` signal channel, the
|
||||
notification listener task, the foreground consumer, `SubscriptionKey` re-sync on
|
||||
observe, and `reset`/`reload` on `StateEvent::SignerChanged`.
|
||||
|
||||
Exit: registry starts and stops cleanly under `cargo check`; listener routes by
|
||||
subscription id only.
|
||||
|
||||
### Phase 5 — Cross-crate fixes
|
||||
|
||||
- `concord::store::{save_state, load_state}` gain `+ ?Sized`.
|
||||
- Chat gift-wrap routing fix.
|
||||
|
||||
Exit: `cargo check -p concord -p chat` passes; no behaviour change for DM-only
|
||||
clients.
|
||||
|
||||
### Phase 6 — App wiring
|
||||
|
||||
Call `community::init(window, cx)` after `chat::init` in `desktop/src/main.rs`
|
||||
and `web/src/lib.rs`.
|
||||
|
||||
Exit: `cargo check -p coop` (or the app targets) passes.
|
||||
|
||||
### Phase 7 — Validation
|
||||
|
||||
Run the four tests plus `cargo check` and `cargo test` for `community`, then the
|
||||
workspace.
|
||||
|
||||
Exit: all green, or failing lines reported with root cause.
|
||||
|
||||
### Phase 8 — Doc finalization
|
||||
|
||||
Reconcile this document with what actually landed (scope, gaps, test names) and
|
||||
remove the draft's speculative sections that were cut.
|
||||
|
||||
Exit: the doc matches the code.
|
||||
@@ -16,6 +16,7 @@ common = { path = "../crates/common" }
|
||||
state = { path = "../crates/state" }
|
||||
device = { path = "../crates/device" }
|
||||
chat = { path = "../crates/chat" }
|
||||
community = { path = "../crates/community" }
|
||||
settings = { path = "../crates/settings" }
|
||||
person = { path = "../crates/person" }
|
||||
|
||||
|
||||
+9
-8
@@ -47,8 +47,6 @@ pub fn run() -> Result<(), JsValue> {
|
||||
};
|
||||
|
||||
app.run(|cx| {
|
||||
// Open the root window
|
||||
cx.open_window(WindowOptions::default(), |window, cx| {
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
@@ -56,23 +54,26 @@ pub fn run() -> Result<(), JsValue> {
|
||||
theme::init(cx);
|
||||
|
||||
// Initialize settings
|
||||
settings::init(window, cx);
|
||||
settings::init(cx);
|
||||
|
||||
// Initialize the nostr client
|
||||
state::init(window, cx, None);
|
||||
state::init(cx, None);
|
||||
|
||||
// Initialize person registry
|
||||
person::init(window, cx);
|
||||
person::init(cx);
|
||||
|
||||
// Initialize device signer
|
||||
//
|
||||
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
device::init(window, cx);
|
||||
device::init(cx);
|
||||
|
||||
// Initialize app registry
|
||||
chat::init(window, cx);
|
||||
chat::init(cx);
|
||||
|
||||
// Root view
|
||||
// Initialize community registry
|
||||
community::init(cx);
|
||||
|
||||
cx.open_window(WindowOptions::default(), |window, cx| {
|
||||
cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx))
|
||||
})
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
|
||||
Reference in New Issue
Block a user