diff --git a/Cargo.lock b/Cargo.lock
index fcf6c28d..4d3732cb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1274,6 +1274,41 @@ dependencies = [
"regex",
]
+[[package]]
+name = "community"
+version = "1.0.2"
+dependencies = [
+ "anyhow",
+ "concord",
+ "flume 0.11.1",
+ "futures",
+ "gpui-pre",
+ "log",
+ "nostr-memory",
+ "nostr-sdk",
+ "serde_json",
+ "smallvec",
+ "smol",
+ "state",
+]
+
+[[package]]
+name = "community_ui"
+version = "1.0.2"
+dependencies = [
+ "anyhow",
+ "common",
+ "community",
+ "gpui-pre",
+ "nostr-sdk",
+ "person",
+ "settings",
+ "smallvec",
+ "state",
+ "theme",
+ "ui",
+]
+
[[package]]
name = "compression-codecs"
version = "0.4.43"
@@ -1297,12 +1332,12 @@ name = "concord"
version = "1.0.2"
dependencies = [
"anyhow",
+ "base64 0.22.1",
"chacha20 0.9.1",
"data-encoding",
"hkdf",
"hmac 0.12.1",
"nostr",
- "nostr-memory",
"nostr-sdk",
"rand 0.10.2",
"serde",
@@ -1412,6 +1447,7 @@ dependencies = [
"auto_update",
"chat",
"common",
+ "community",
"device",
"gpui-pre",
"gpui-pre-linux",
@@ -1437,6 +1473,7 @@ dependencies = [
"assets",
"chat",
"common",
+ "community",
"console_error_panic_hook",
"console_log",
"device",
@@ -9323,6 +9360,8 @@ dependencies = [
"chat",
"chat_ui",
"common",
+ "community",
+ "community_ui",
"device",
"gpui-pre",
"instant",
diff --git a/Cargo.toml b/Cargo.toml
index 8ced0833..c7968032 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -36,6 +36,7 @@ nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "ni
# Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10"
+base64 = "0.22"
sha2 = "0.10"
data-encoding = "2"
hkdf = "0.12"
diff --git a/assets/icons/compass.svg b/assets/icons/compass.svg
new file mode 100644
index 00000000..6cd227dc
--- /dev/null
+++ b/assets/icons/compass.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/folder.svg b/assets/icons/folder.svg
new file mode 100644
index 00000000..65967a9b
--- /dev/null
+++ b/assets/icons/folder.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/history.svg b/assets/icons/history.svg
new file mode 100644
index 00000000..e7c76e68
--- /dev/null
+++ b/assets/icons/history.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/message.svg b/assets/icons/message.svg
new file mode 100644
index 00000000..87e04016
--- /dev/null
+++ b/assets/icons/message.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/auto_update/src/lib.rs b/crates/auto_update/src/lib.rs
index ce962806..92b4c63d 100644
--- a/crates/auto_update/src/lib.rs
+++ b/crates/auto_update/src/lib.rs
@@ -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);
@@ -103,21 +100,17 @@ impl AutoUpdater {
cx.set_global(GlobalAutoUpdater(state));
}
- fn new(
- window: &mut Window,
- version: Version,
- filter: AssetFilter,
- cx: &mut Context,
- ) -> Self {
+ fn new(version: Version, filter: AssetFilter, cx: &mut Context) -> 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();
});
diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs
index 0563c87c..d9b22462 100644
--- a/crates/chat/src/lib.rs
+++ b/crates/chat/src/lib.rs
@@ -26,8 +26,8 @@ pub use state::FileAttachment;
/// A static keypair used only for signing locally-cached rumor events.
static LOCAL_KEYS: LazyLock = 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);
@@ -150,7 +150,8 @@ impl ChatRegistry {
}
/// Create a new chat registry instance
- fn new(window: &mut Window, cx: &mut Context) -> Self {
+ fn new(cx: &mut Context) -> Self {
+ let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx);
let (tx, rx) = flume::unbounded::();
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| {
- this.get_rooms(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();
diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs
index 094209b0..8bcb3af9 100644
--- a/crates/chat/src/room.rs
+++ b/crates/chat/src/room.rs
@@ -289,12 +289,21 @@ impl Room {
}
}
- /// Gets the display image for the room
- pub fn display_image(&self, cx: &App) -> SharedString {
- if !self.is_group() {
- self.display_member(cx).avatar()
+ /// Gets the display picture for the room, if it has one
+ pub fn display_image(&self, cx: &App) -> Option {
+ if self.is_group() {
+ None
} else {
- SharedString::from("brand/group.png")
+ self.display_member(cx).avatar()
+ }
+ }
+
+ /// A stable seed for the room's generated avatar
+ pub fn display_image_seed(&self, cx: &App) -> SharedString {
+ if self.is_group() {
+ SharedString::from(self.id.to_string())
+ } else {
+ self.display_member(cx).avatar_seed()
}
}
diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs
index 0b54972d..c6a23955 100644
--- a/crates/chat_ui/src/lib.rs
+++ b/crates/chat_ui/src/lib.rs
@@ -1203,6 +1203,7 @@ impl ChatPanel {
if show_author {
this.child(
Avatar::new(author.avatar())
+ .seed(author.avatar_seed())
.flex_shrink_0()
.relative()
.dropdown_menu(move |this, _window, _cx| {
@@ -1470,7 +1471,7 @@ impl ChatPanel {
h_flex()
.gap_1()
.font_semibold()
- .child(Avatar::new(avatar).small())
+ .child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
.child(name.clone()),
),
)
@@ -1978,11 +1979,12 @@ impl Panel for ChatPanel {
self.room
.read_with(cx, |this, cx| {
let label = this.display_name(cx);
- let url = this.display_image(cx);
+ let picture = this.display_image(cx);
+ let seed = this.display_image_seed(cx);
h_flex()
.gap_1p5()
- .child(Avatar::new(url).xsmall())
+ .child(Avatar::new(picture).seed(seed).xsmall())
.child(label)
.into_any_element()
})
diff --git a/crates/community/Cargo.toml b/crates/community/Cargo.toml
new file mode 100644
index 00000000..b4811929
--- /dev/null
+++ b/crates/community/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "community"
+version.workspace = true
+edition.workspace = true
+publish.workspace = true
+
+[dependencies]
+concord = { path = "../concord" }
+state = { path = "../state" }
+
+gpui.workspace = true
+nostr-sdk.workspace = true
+
+anyhow.workspace = true
+flume.workspace = true
+futures.workspace = true
+log.workspace = true
+serde_json.workspace = true
+smallvec.workspace = true
+smol.workspace = true
+
+[dev-dependencies]
+nostr-memory.workspace = true
diff --git a/crates/community/src/cache.rs b/crates/community/src/cache.rs
new file mode 100644
index 00000000..12ce5e18
--- /dev/null
+++ b/crates/community/src/cache.rs
@@ -0,0 +1,331 @@
+use std::collections::BTreeMap;
+use std::sync::LazyLock;
+
+use anyhow::{Result, anyhow};
+use concord::cord01::OpenedStream;
+use concord::state::{CommunityState, STATE_PREFIX, state_identifier};
+use concord::{ChannelId, CommunityId, cord03};
+use nostr_sdk::prelude::*;
+
+static LOCAL_KEYS: LazyLock = LazyLock::new(|| {
+ Keys::new(SecretKey::from_slice(&[0x43; 32]).expect("a fixed 32-byte scalar is a valid key"))
+});
+
+const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C;
+const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
+const MARK_VALUE: &str = "concord";
+const WRAP_TAG: &str = "e";
+const KIND_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_K;
+
+/// An already-expired rumor is refused at ingest. Returns whether it was kept.
+pub async fn cache_rumor(
+ client: &Client,
+ channel: &ChannelId,
+ opened: &OpenedStream,
+) -> Result {
+ let at = Timestamp::from_secs(opened.at_ms / 1000);
+
+ if cord03::expiration_of(&opened.rumor)?
+ .is_some_and(|expiration| expiration <= Timestamp::now())
+ {
+ return Ok(false);
+ }
+
+ let tags = vec![
+ Tag::identifier(opened.rumor_id),
+ Tag::custom(KIND_TAG.as_str(), [opened.rumor.kind.to_string()]),
+ Tag::custom(WRAP_TAG, [opened.wrapper_id.to_string()]),
+ Tag::custom(MARK_TAG.as_str(), [MARK_VALUE]),
+ Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]),
+ Tag::public_key(opened.author),
+ ];
+
+ let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
+ .tags(tags)
+ .custom_created_at(at)
+ .finalize_async(&*LOCAL_KEYS)
+ .await?;
+
+ client.database().save_event(&event).await?;
+
+ Ok(true)
+}
+
+pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp) -> Result {
+ let filter = Filter::new()
+ .kind(Kind::ApplicationSpecificData)
+ .custom_tag(MARK_TAG, MARK_VALUE)
+ .custom_tag(CHANNEL_TAG, channel.to_hex());
+
+ let mut expired = Vec::new();
+
+ for event in client.database().query(filter).await? {
+ let Ok(rumor) = UnsignedEvent::from_json(&event.content) else {
+ continue;
+ };
+
+ let Ok(Some(expiration)) = cord03::expiration_of(&rumor) else {
+ continue;
+ };
+
+ if expiration <= now {
+ expired.push(event.id);
+ }
+ }
+
+ let purged = expired.len();
+
+ if purged > 0 {
+ client.database().delete(Filter::new().ids(expired)).await?;
+ }
+
+ Ok(purged)
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Observed {
+ pub author: PublicKey,
+ pub at_ms: u64,
+}
+
+/// The cached rumors of `channel`, keyed by the wrap they were opened from.
+pub async fn wrapper_index(
+ client: &Client,
+ channel: &ChannelId,
+) -> Result> {
+ let filter = Filter::new()
+ .kind(Kind::ApplicationSpecificData)
+ .custom_tag(MARK_TAG, MARK_VALUE)
+ .custom_tag(CHANNEL_TAG, channel.to_hex());
+
+ let mut index = BTreeMap::new();
+
+ for event in client.database().query(filter).await? {
+ let (Some(wrapper_id), Some(author)) = (
+ event.tags.event_ids().next(),
+ event.tags.public_keys().next(),
+ ) else {
+ continue;
+ };
+
+ index.insert(
+ wrapper_id,
+ Observed {
+ author,
+ at_ms: event.created_at.as_secs().saturating_mul(1000),
+ },
+ );
+ }
+
+ Ok(index)
+}
+
+/// Cached rumors for `channel`, newest first, deduplicated by rumor id.
+pub async fn query_rumors(
+ client: &Client,
+ channel: &ChannelId,
+ until: Option,
+ limit: usize,
+ kinds: Option<&[u16]>,
+) -> Result> {
+ let mut filter = Filter::new()
+ .kind(Kind::ApplicationSpecificData)
+ .custom_tag(MARK_TAG, MARK_VALUE)
+ .custom_tag(CHANNEL_TAG, channel.to_hex());
+
+ if let Some(kinds) = kinds {
+ filter = filter.custom_tags(KIND_TAG, kinds.iter().map(u16::to_string));
+ }
+
+ if let Some(until) = until {
+ filter = filter.until(until);
+ }
+
+ let mut newest: BTreeMap = BTreeMap::new();
+ for event in client.database().query(filter).await? {
+ let Some(rumor_id) = event.tags.identifier() else {
+ continue;
+ };
+
+ match newest.get(&rumor_id) {
+ Some(existing) if existing.created_at >= event.created_at => {}
+ _ => {
+ newest.insert(rumor_id, event);
+ }
+ }
+ }
+
+ let mut events: Vec = newest.into_values().collect();
+ events.sort_by_key(|event| std::cmp::Reverse(event.created_at));
+ events.truncate(limit);
+
+ let mut rumors = Vec::with_capacity(events.len());
+ for event in events {
+ let rumor = UnsignedEvent::from_json(event.content)
+ .map_err(|error| anyhow!("cached rumor is not a valid event: {error}"))?;
+ rumors.push(rumor);
+ }
+
+ Ok(rumors)
+}
+
+pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> {
+ let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
+ .tags([Tag::identifier(state.identifier())])
+ .finalize_async(&*LOCAL_KEYS)
+ .await?;
+
+ client.database().save_event(&event).await?;
+
+ Ok(())
+}
+
+pub async fn load_state(client: &Client, id: &CommunityId) -> Result