diff --git a/Cargo.lock b/Cargo.lock
index 64364dd7..0f72adfa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1276,41 +1276,6 @@ 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"
@@ -1449,7 +1414,6 @@ dependencies = [
"auto_update",
"chat",
"common",
- "community",
"device",
"gpui-pre",
"gpui-pre-linux",
@@ -1458,6 +1422,7 @@ dependencies = [
"gpui-pre-reqwest-client",
"gpui-pre-windows",
"log",
+ "nip29",
"nostr-sdk",
"person",
"settings",
@@ -1475,7 +1440,6 @@ dependencies = [
"assets",
"chat",
"common",
- "community",
"console_error_panic_hook",
"console_log",
"device",
@@ -1487,6 +1451,7 @@ dependencies = [
"gpui-pre-web",
"instant",
"log",
+ "nip29",
"person",
"settings",
"state",
@@ -4593,6 +4558,44 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+[[package]]
+name = "nip29"
+version = "1.0.2"
+dependencies = [
+ "anyhow",
+ "chat",
+ "flume 0.11.1",
+ "futures",
+ "gpui-pre",
+ "log",
+ "nostr",
+ "nostr-sdk",
+ "serde_json",
+ "smallvec",
+ "state",
+]
+
+[[package]]
+name = "nip29_ui"
+version = "1.0.2"
+dependencies = [
+ "anyhow",
+ "chat",
+ "chat_ui",
+ "common",
+ "gpui-pre",
+ "log",
+ "nip29",
+ "nostr-sdk",
+ "person",
+ "serde",
+ "settings",
+ "smallvec",
+ "state",
+ "theme",
+ "ui",
+]
+
[[package]]
name = "no_std_io2"
version = "0.9.4"
@@ -9479,12 +9482,12 @@ dependencies = [
"chat",
"chat_ui",
"common",
- "community",
- "community_ui",
"device",
"gpui-pre",
"instant",
"log",
+ "nip29",
+ "nip29_ui",
"nostr-connect",
"nostr-sdk",
"person",
diff --git a/Cargo.toml b/Cargo.toml
index 50839b5b..8ca3bc30 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -32,7 +32,7 @@ nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
nostr-gossip-memory = { 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" }
-nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
+nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
# Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10"
diff --git a/assets/icons/ban.svg b/assets/icons/ban.svg
new file mode 100644
index 00000000..0129765f
--- /dev/null
+++ b/assets/icons/ban.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/edit.svg b/assets/icons/edit.svg
new file mode 100644
index 00000000..9387f08a
--- /dev/null
+++ b/assets/icons/edit.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/login.svg b/assets/icons/login.svg
new file mode 100644
index 00000000..dc5ec458
--- /dev/null
+++ b/assets/icons/login.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/logout.svg b/assets/icons/logout.svg
new file mode 100644
index 00000000..05534526
--- /dev/null
+++ b/assets/icons/logout.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/upload.svg b/assets/icons/upload.svg
index cfb5e305..d92353e7 100644
--- a/assets/icons/upload.svg
+++ b/assets/icons/upload.svg
@@ -1,3 +1 @@
-
+
diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs
index 7cdd7670..7c180368 100644
--- a/crates/chat_ui/src/lib.rs
+++ b/crates/chat_ui/src/lib.rs
@@ -53,7 +53,7 @@ static EMOJI_RE: LazyLock =
mod actions;
mod file;
-mod text;
+pub mod text;
pub fn init(room: WeakEntity, window: &mut Window, cx: &mut App) -> Entity {
cx.new(|cx| ChatPanel::new(room, window, cx))
diff --git a/crates/community/src/cache.rs b/crates/community/src/cache.rs
deleted file mode 100644
index 12ce5e18..00000000
--- a/crates/community/src/cache.rs
+++ /dev/null
@@ -1,331 +0,0 @@
-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