update backend
This commit is contained in:
Generated
+1
-1
@@ -1281,6 +1281,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"concord",
|
"concord",
|
||||||
"flume 0.11.1",
|
"flume 0.11.1",
|
||||||
|
"futures",
|
||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"log",
|
"log",
|
||||||
"nostr-memory",
|
"nostr-memory",
|
||||||
@@ -1339,7 +1340,6 @@ dependencies = [
|
|||||||
"hmac 0.12.1",
|
"hmac 0.12.1",
|
||||||
"log",
|
"log",
|
||||||
"nostr",
|
"nostr",
|
||||||
"nostr-memory",
|
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ nostr-sdk.workspace = true
|
|||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
nostr-memory.workspace = true
|
nostr-memory.workspace = true
|
||||||
smol.workspace = true
|
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
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<Keys> = 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<bool> {
|
||||||
|
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<usize> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached rumors for `channel`, newest first, deduplicated by rumor id.
|
||||||
|
pub async fn query_rumors(
|
||||||
|
client: &Client,
|
||||||
|
channel: &ChannelId,
|
||||||
|
until: Option<Timestamp>,
|
||||||
|
limit: usize,
|
||||||
|
kinds: Option<&[u16]>,
|
||||||
|
) -> Result<Vec<UnsignedEvent>> {
|
||||||
|
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<String, Event> = 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<Event> = 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<Option<CommunityState>> {
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.identifier(state_identifier(id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
match client.database().query(filter).await?.into_iter().next() {
|
||||||
|
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest state document per community carried in the local database.
|
||||||
|
pub async fn load_states(client: &Client) -> Result<Vec<CommunityState>> {
|
||||||
|
let filter = Filter::new().kind(Kind::ApplicationSpecificData);
|
||||||
|
let mut newest: BTreeMap<CommunityId, Event> = BTreeMap::new();
|
||||||
|
|
||||||
|
for event in client.database().query(filter).await? {
|
||||||
|
let Some(id) = state_document_of(&event) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match newest.get(&id) {
|
||||||
|
Some(existing) if existing.created_at >= event.created_at => {}
|
||||||
|
_ => {
|
||||||
|
newest.insert(id, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut states = Vec::with_capacity(newest.len());
|
||||||
|
|
||||||
|
for event in newest.into_values() {
|
||||||
|
match serde_json::from_str::<CommunityState>(&event.content) {
|
||||||
|
Ok(state) => states.push(state),
|
||||||
|
Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(states)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_document_of(event: &Event) -> Option<CommunityId> {
|
||||||
|
let identifier = event.tags.identifier()?;
|
||||||
|
let hex = identifier.strip_prefix(STATE_PREFIX)?;
|
||||||
|
hex.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use concord::Epoch;
|
||||||
|
use nostr_memory::MemoryDatabase;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn client() -> Client {
|
||||||
|
ClientBuilder::default()
|
||||||
|
.database(MemoryDatabase::unbounded())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_states_reads_one_document_per_community_and_ignores_other_documents() {
|
||||||
|
smol::block_on(async {
|
||||||
|
let client = client();
|
||||||
|
|
||||||
|
let state = CommunityState {
|
||||||
|
id: CommunityId::from_bytes([0x42; 32]),
|
||||||
|
name: Some("Anime and Manga".to_owned()),
|
||||||
|
owner: Keys::generate().public_key(),
|
||||||
|
owner_salt: [0x01; 32],
|
||||||
|
community_root: [0x02; 32],
|
||||||
|
root_epoch: Epoch(0),
|
||||||
|
control_root: None,
|
||||||
|
control_pks: BTreeMap::new(),
|
||||||
|
channels: Vec::new(),
|
||||||
|
relays: Vec::new(),
|
||||||
|
heads: Vec::new(),
|
||||||
|
banned: BTreeSet::new(),
|
||||||
|
cursors: BTreeMap::new(),
|
||||||
|
dissolved: false,
|
||||||
|
added_at_ms: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
save_state(&client, &state).await.expect("saves");
|
||||||
|
|
||||||
|
// A cached rumor is also an application-specific document, but not a
|
||||||
|
// state document, so the prefix keeps it out of the state scan.
|
||||||
|
let other = EventBuilder::new(Kind::ApplicationSpecificData, "{}")
|
||||||
|
.tags([Tag::identifier("deadbeef")])
|
||||||
|
.finalize(&*LOCAL_KEYS)
|
||||||
|
.expect("builds");
|
||||||
|
client.database().save_event(&other).await.expect("saves");
|
||||||
|
|
||||||
|
let loaded = load_states(&client).await.expect("loads");
|
||||||
|
|
||||||
|
assert_eq!(loaded, vec![state]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn caching_the_same_rumor_twice_leaves_one_row() {
|
||||||
|
smol::block_on(async {
|
||||||
|
let client = client();
|
||||||
|
let channel = ChannelId::from_bytes([0x9c; 32]);
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let group = concord::derive::channel_group_key(&[0x07; 32], &channel, Epoch(0))
|
||||||
|
.expect("a group key");
|
||||||
|
|
||||||
|
let rumor = concord::cord03::build_message(
|
||||||
|
keys.public_key(),
|
||||||
|
&channel,
|
||||||
|
Epoch(0),
|
||||||
|
"twice",
|
||||||
|
None,
|
||||||
|
1_700_000_000_000,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let (wrap, _) = concord::cord03::seal_rumor(&rumor, &group, &keys, false)
|
||||||
|
.await
|
||||||
|
.expect("seals");
|
||||||
|
let (opened, _) =
|
||||||
|
concord::cord03::open(&wrap, &group, &channel, Epoch(0)).expect("opens");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cache_rumor(&client, &channel, &opened)
|
||||||
|
.await
|
||||||
|
.expect("caches")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
cache_rumor(&client, &channel, &opened)
|
||||||
|
.await
|
||||||
|
.expect("caches")
|
||||||
|
);
|
||||||
|
|
||||||
|
let cached = query_rumors(&client, &channel, None, 10, None)
|
||||||
|
.await
|
||||||
|
.expect("reads");
|
||||||
|
assert_eq!(cached.len(), 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -6,15 +6,63 @@ use concord::cord02::{ControlFold, ImageRef};
|
|||||||
use concord::cord03::{self, ChatMessage, ReplyRef};
|
use concord::cord03::{self, ChatMessage, ReplyRef};
|
||||||
use concord::cord04::roles::{Permissions, citation_ok};
|
use concord::cord04::roles::{Permissions, citation_ok};
|
||||||
use concord::derive::channel_group_key;
|
use concord::derive::channel_group_key;
|
||||||
use concord::store::{self, ChannelKeyRef, CommunityState};
|
use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState};
|
||||||
use concord::{ChannelId, CommunityId, Epoch};
|
use concord::{ChannelId, CommunityId, Epoch};
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, Task};
|
use gpui::{App, AppContext, Context, EventEmitter, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
|
|
||||||
|
use crate::cache;
|
||||||
|
use crate::history::{self, Window, WrapPage};
|
||||||
use crate::sync::{self, Snapshot};
|
use crate::sync::{self, Snapshot};
|
||||||
|
|
||||||
const MESSAGE_LIMIT: usize = 200;
|
/// Wraps one relay returns for one page request.
|
||||||
|
const PAGE_WRAPS: usize = 50;
|
||||||
|
/// Pages a catch-up round walks down a channel's history on its own.
|
||||||
|
const CATCH_UP_PAGES: usize = 20;
|
||||||
|
/// Pages one explicit "load older" fetches from the relays.
|
||||||
|
pub const LOAD_OLDER_PAGES: usize = 6;
|
||||||
|
/// Rows one timeline read returns before the caller asks for more.
|
||||||
|
pub const TIMELINE_PAGE: usize = 100;
|
||||||
|
/// Side events read per row, so a reaction flood cannot displace the rows it
|
||||||
|
/// decorates.
|
||||||
|
const SIDE_EVENT_FACTOR: usize = 4;
|
||||||
|
|
||||||
|
/// Which direction a sync round reads.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Intent {
|
||||||
|
CatchUp,
|
||||||
|
Older { pages: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one round saw, for the caller to report and act on.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct Progress {
|
||||||
|
pub fetched: usize,
|
||||||
|
pub opened: usize,
|
||||||
|
pub exhausted: bool,
|
||||||
|
pub failed: bool,
|
||||||
|
pub errors: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A channel's timeline, folded from the local cache.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Timeline {
|
||||||
|
/// Oldest first, ready for a bottom-aligned list.
|
||||||
|
pub messages: Vec<ChatMessage>,
|
||||||
|
/// The cache holds rows older than `messages`.
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One channel's round in flight, coalescing requests that arrive while it runs.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Round {
|
||||||
|
running: bool,
|
||||||
|
/// The next round to run once this one lands.
|
||||||
|
queued: Option<Intent>,
|
||||||
|
/// Everyone waiting on the outcome.
|
||||||
|
waiters: Vec<flume::Sender<Result<Progress, String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SubscriptionKey {
|
pub struct SubscriptionKey {
|
||||||
@@ -64,6 +112,7 @@ pub struct Community {
|
|||||||
refresh_task: Option<Task<Result<()>>>,
|
refresh_task: Option<Task<Result<()>>>,
|
||||||
icon_task: Option<Task<Result<()>>>,
|
icon_task: Option<Task<Result<()>>>,
|
||||||
banner_task: Option<Task<Result<()>>>,
|
banner_task: Option<Task<Result<()>>>,
|
||||||
|
rounds: HashMap<ChannelId, Round>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<CommunityEvent> for Community {}
|
impl EventEmitter<CommunityEvent> for Community {}
|
||||||
@@ -83,6 +132,7 @@ impl Community {
|
|||||||
refresh_task: None,
|
refresh_task: None,
|
||||||
icon_task: None,
|
icon_task: None,
|
||||||
banner_task: None,
|
banner_task: None,
|
||||||
|
rounds: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,31 +212,168 @@ impl Community {
|
|||||||
Some((held.epoch, self.state.community_root))
|
Some((held.epoch, self.state.community_root))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Page a channel's history into the local cache, once per channel.
|
/// Every secret the client holds for a channel, newest epoch first.
|
||||||
pub fn backfill(&self, channel: &ChannelId, cx: &App) -> Task<Result<()>> {
|
fn held_keys(&self, channel: &ChannelId) -> Vec<(Epoch, [u8; 32])> {
|
||||||
let Some((epoch, secret)) = self.channel_secret(channel) else {
|
self.channel_secret(channel).into_iter().collect()
|
||||||
return Task::ready(Ok(()));
|
|
||||||
};
|
|
||||||
|
|
||||||
let client = NostrRegistry::global(cx).read(cx).client();
|
|
||||||
let channel = *channel;
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
if !store::query_rumors(&client, &channel, None, 1)
|
|
||||||
.await?
|
|
||||||
.is_empty()
|
|
||||||
{
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
store::backfill(&client, &channel, &[(epoch, secret)], None, MESSAGE_LIMIT).await?;
|
pub fn sync_channel(
|
||||||
|
&mut self,
|
||||||
|
channel: &ChannelId,
|
||||||
|
intent: Intent,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<Progress>> {
|
||||||
|
let channel = *channel;
|
||||||
|
let (sender, receiver) = flume::bounded(1);
|
||||||
|
|
||||||
Ok(())
|
let start = {
|
||||||
|
let round = self.rounds.entry(channel).or_default();
|
||||||
|
round.waiters.push(sender);
|
||||||
|
|
||||||
|
let start = !round.running;
|
||||||
|
|
||||||
|
if !start {
|
||||||
|
round.queued = Some(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
start
|
||||||
|
};
|
||||||
|
|
||||||
|
if start {
|
||||||
|
self.start_round(channel, intent, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let progress = match receiver.recv_async().await {
|
||||||
|
Ok(progress) => progress,
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("community: channel round cancelled: {error}");
|
||||||
|
return Ok(Progress {
|
||||||
|
failed: true,
|
||||||
|
..Progress::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
progress.map_err(anyhow::Error::msg)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn start_round(&mut self, channel: ChannelId, intent: Intent, cx: &mut Context<Self>) {
|
||||||
|
let client = NostrRegistry::global(cx).read(cx).client();
|
||||||
|
let held = self.held_keys(&channel);
|
||||||
|
let relays = self.state.relays.clone();
|
||||||
|
|
||||||
|
let saved = self
|
||||||
|
.state
|
||||||
|
.cursors
|
||||||
|
.get(&channel)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if let Some(round) = self.rounds.get_mut(&channel) {
|
||||||
|
round.running = true;
|
||||||
|
round.queued = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let round = cx.background_spawn(async move {
|
||||||
|
sync_round(&client, &channel, &held, &relays, saved, intent).await
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let result = round.await;
|
||||||
|
|
||||||
|
if let Err(error) = this.update(cx, |this, cx| this.finish_round(channel, result, cx)) {
|
||||||
|
log::warn!("community: a channel round outlived its community: {error}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_round(
|
||||||
|
&mut self,
|
||||||
|
channel: ChannelId,
|
||||||
|
result: Result<RoundOutcome>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let outcome = match result {
|
||||||
|
Ok((progress, cursor)) => {
|
||||||
|
self.merge_cursor(channel, cursor, cx);
|
||||||
|
Ok(progress)
|
||||||
|
}
|
||||||
|
Err(error) => Err(error.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(round) = self.rounds.get_mut(&channel) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
round.running = false;
|
||||||
|
let queued = round.queued.take();
|
||||||
|
let waiters = std::mem::take(&mut round.waiters);
|
||||||
|
|
||||||
|
for waiter in waiters {
|
||||||
|
if let Err(error) = waiter.try_send(outcome.clone()) {
|
||||||
|
log::warn!("community: a channel round result was not delivered: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(intent) = queued {
|
||||||
|
self.start_round(channel, intent, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(progress) = &outcome
|
||||||
|
&& progress.failed
|
||||||
|
&& progress.errors > 0
|
||||||
|
&& progress.fetched == 0
|
||||||
|
{
|
||||||
|
cx.emit(CommunityEvent::Error(format!(
|
||||||
|
"could not reach {} of the community's relays",
|
||||||
|
progress.errors
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.emit(CommunityEvent::Updated(self.state.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold a round's cursor findings in, monotonically, and persist them.
|
||||||
|
fn merge_cursor(&mut self, channel: ChannelId, cursor: ChannelCursor, cx: &mut Context<Self>) {
|
||||||
|
let held = self
|
||||||
|
.state
|
||||||
|
.cursors
|
||||||
|
.get(&channel)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let merged = held.merge(cursor);
|
||||||
|
|
||||||
|
if merged == held {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state.cursors.insert(channel, merged);
|
||||||
|
self.persist(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the local state document out.
|
||||||
|
fn persist(&self, cx: &Context<Self>) {
|
||||||
|
let client = NostrRegistry::global(cx).read(cx).client();
|
||||||
|
let state = self.state.clone();
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
if let Err(error) = cache::save_state(&client, &state).await {
|
||||||
|
log::warn!("community: failed to persist the local state: {error}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
/// The channel's timeline, folded from the local cache, oldest first.
|
/// The channel's timeline, folded from the local cache, oldest first.
|
||||||
pub fn messages(&self, channel: &ChannelId, cx: &App) -> Task<Result<Vec<ChatMessage>>> {
|
pub fn timeline(
|
||||||
|
&self,
|
||||||
|
channel: &ChannelId,
|
||||||
|
before_ms: Option<u64>,
|
||||||
|
limit: usize,
|
||||||
|
cx: &App,
|
||||||
|
) -> Task<Result<Timeline>> {
|
||||||
let client = NostrRegistry::global(cx).read(cx).client();
|
let client = NostrRegistry::global(cx).read(cx).client();
|
||||||
let channel = *channel;
|
let channel = *channel;
|
||||||
let owner = self.state.owner;
|
let owner = self.state.owner;
|
||||||
@@ -195,10 +382,31 @@ impl Community {
|
|||||||
let roles = self.control.roles.clone();
|
let roles = self.control.roles.clone();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let cached = store::query_rumors(&client, &channel, None, MESSAGE_LIMIT).await?;
|
let until = before_ms.map(|before_ms| Timestamp::from_secs(before_ms / 1000));
|
||||||
let mut rumors = Vec::with_capacity(cached.len());
|
|
||||||
|
|
||||||
for rumor in &cached {
|
let rows = cache::query_rumors(
|
||||||
|
&client,
|
||||||
|
&channel,
|
||||||
|
until,
|
||||||
|
limit.saturating_add(1),
|
||||||
|
Some(&cord03::ROW_KINDS),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let has_more = rows.len() > limit;
|
||||||
|
|
||||||
|
let sides = cache::query_rumors(
|
||||||
|
&client,
|
||||||
|
&channel,
|
||||||
|
until,
|
||||||
|
limit.saturating_mul(SIDE_EVENT_FACTOR),
|
||||||
|
Some(&cord03::SIDE_KINDS),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut rumors = Vec::with_capacity(rows.len() + sides.len());
|
||||||
|
|
||||||
|
for rumor in rows.iter().take(limit).chain(sides.iter()) {
|
||||||
match cord03::parse_rumor(rumor) {
|
match cord03::parse_rumor(rumor) {
|
||||||
Ok(chat) => rumors.push(chat),
|
Ok(chat) => rumors.push(chat),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -220,7 +428,7 @@ impl Community {
|
|||||||
|
|
||||||
messages.reverse();
|
messages.reverse();
|
||||||
|
|
||||||
Ok(messages)
|
Ok(Timeline { messages, has_more })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +472,7 @@ impl Community {
|
|||||||
let (wrap, _) = cord03::seal_rumor(&rumor, &group, &signer, false).await?;
|
let (wrap, _) = cord03::seal_rumor(&rumor, &group, &signer, false).await?;
|
||||||
|
|
||||||
let (opened, _) = cord03::open(&wrap, &group, &channel, epoch)?;
|
let (opened, _) = cord03::open(&wrap, &group, &channel, epoch)?;
|
||||||
store::cache_rumor(&client, &channel, &opened).await?;
|
cache::cache_rumor(&client, &channel, &opened).await?;
|
||||||
|
|
||||||
sync::connect_relays(&client, &relays).await;
|
sync::connect_relays(&client, &relays).await;
|
||||||
sync::publish_wrap(&client, &wrap, &relays).await;
|
sync::publish_wrap(&client, &wrap, &relays).await;
|
||||||
@@ -308,7 +516,9 @@ impl Community {
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Some(snapshot)) => {
|
Ok(Some(snapshot)) => {
|
||||||
self.state = snapshot.state;
|
let mut state = snapshot.state;
|
||||||
|
state.cursors = std::mem::take(&mut self.state.cursors);
|
||||||
|
self.state = state;
|
||||||
self.control = snapshot.control;
|
self.control = snapshot.control;
|
||||||
self.members = snapshot.members;
|
self.members = snapshot.members;
|
||||||
self.load_images(cx);
|
self.load_images(cx);
|
||||||
@@ -388,3 +598,112 @@ impl Community {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One round's progress and the cursor material it earned.
|
||||||
|
type RoundOutcome = (Progress, ChannelCursor);
|
||||||
|
|
||||||
|
/// Read a channel's history from the community's relays, in three passes.
|
||||||
|
async fn sync_round(
|
||||||
|
client: &Client,
|
||||||
|
channel: &ChannelId,
|
||||||
|
held: &[(Epoch, [u8; 32])],
|
||||||
|
relays: &[RelayUrl],
|
||||||
|
saved: ChannelCursor,
|
||||||
|
intent: Intent,
|
||||||
|
) -> Result<RoundOutcome> {
|
||||||
|
let mut progress = Progress::default();
|
||||||
|
let mut round = ChannelCursor::default();
|
||||||
|
|
||||||
|
let newest = match intent {
|
||||||
|
Intent::CatchUp => {
|
||||||
|
let page = history::page(
|
||||||
|
client,
|
||||||
|
channel,
|
||||||
|
held,
|
||||||
|
relays,
|
||||||
|
Window::newest(),
|
||||||
|
1,
|
||||||
|
PAGE_WRAPS,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
absorb(&mut progress, &page);
|
||||||
|
page
|
||||||
|
}
|
||||||
|
Intent::Older { .. } => WrapPage::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let bridge = match (intent, newest.oldest_ms, saved.newest_ms) {
|
||||||
|
(Intent::CatchUp, Some(oldest), Some(saved_newest)) if oldest > saved_newest => {
|
||||||
|
let page = history::page(
|
||||||
|
client,
|
||||||
|
channel,
|
||||||
|
held,
|
||||||
|
relays,
|
||||||
|
Window::between(saved_newest, oldest),
|
||||||
|
CATCH_UP_PAGES,
|
||||||
|
PAGE_WRAPS,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
absorb(&mut progress, &page);
|
||||||
|
page
|
||||||
|
}
|
||||||
|
// Nothing to bridge, so the newest region is already complete.
|
||||||
|
_ => WrapPage {
|
||||||
|
exhausted: true,
|
||||||
|
..WrapPage::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let resume = match intent {
|
||||||
|
Intent::CatchUp => saved.oldest_ms.or(newest.oldest_ms),
|
||||||
|
Intent::Older { .. } => saved.oldest_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pages = match intent {
|
||||||
|
Intent::CatchUp => CATCH_UP_PAGES,
|
||||||
|
Intent::Older { pages } => pages,
|
||||||
|
};
|
||||||
|
|
||||||
|
let older = match resume {
|
||||||
|
Some(until) => {
|
||||||
|
let page = history::page(
|
||||||
|
client,
|
||||||
|
channel,
|
||||||
|
held,
|
||||||
|
relays,
|
||||||
|
Window::older_than(until),
|
||||||
|
pages,
|
||||||
|
PAGE_WRAPS,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
absorb(&mut progress, &page);
|
||||||
|
page
|
||||||
|
}
|
||||||
|
None => WrapPage::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if intent == Intent::CatchUp {
|
||||||
|
let complete = !newest.failed && bridge.exhausted;
|
||||||
|
let top = newest
|
||||||
|
.newest_ms
|
||||||
|
.unwrap_or(0)
|
||||||
|
.max(bridge.newest_ms.unwrap_or(0));
|
||||||
|
|
||||||
|
if complete && top > 0 {
|
||||||
|
round.newest_ms = Some(top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
round.oldest_ms = older.oldest_ms.or(newest.oldest_ms);
|
||||||
|
round.exhausted = older.exhausted;
|
||||||
|
|
||||||
|
Ok((progress, round))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn absorb(progress: &mut Progress, page: &WrapPage) {
|
||||||
|
progress.fetched += page.raw;
|
||||||
|
progress.opened += page.opened.len();
|
||||||
|
progress.exhausted |= page.exhausted;
|
||||||
|
progress.failed |= page.failed;
|
||||||
|
progress.errors += page.errors;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use concord::cord01::KIND_WRAP_EPHEMERAL;
|
||||||
|
use concord::cord03::{self, ChatRumor, plane_keys};
|
||||||
|
use concord::{ChannelId, Epoch};
|
||||||
|
use futures::future::{Either, join_all, select};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
use crate::cache::cache_rumor;
|
||||||
|
|
||||||
|
/// How long one relay is given to answer one page of history.
|
||||||
|
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// The region of history to read.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct Window {
|
||||||
|
pub until_ms: Option<u64>,
|
||||||
|
pub since_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Window {
|
||||||
|
/// The newest wraps, with no older bound.
|
||||||
|
pub fn newest() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wraps strictly older than `oldest_ms`.
|
||||||
|
pub fn older_than(oldest_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
until_ms: Some(oldest_ms.saturating_sub(1)),
|
||||||
|
since_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The region between `since_ms` and `oldest_ms`, both inclusive.
|
||||||
|
pub fn between(since_ms: u64, oldest_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
until_ms: Some(oldest_ms.saturating_sub(1)),
|
||||||
|
since_ms: Some(since_ms),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one paged fetch saw.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct WrapPage {
|
||||||
|
pub opened: Vec<ChatRumor>,
|
||||||
|
pub raw: usize,
|
||||||
|
pub newest_ms: Option<u64>,
|
||||||
|
pub oldest_ms: Option<u64>,
|
||||||
|
pub exhausted: bool,
|
||||||
|
pub failed: bool,
|
||||||
|
pub errors: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walks a channel's history back over the community's own relays.
|
||||||
|
pub async fn page(
|
||||||
|
client: &Client,
|
||||||
|
channel: &ChannelId,
|
||||||
|
held: &[(Epoch, [u8; 32])],
|
||||||
|
relays: &[RelayUrl],
|
||||||
|
window: Window,
|
||||||
|
max_pages: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<WrapPage> {
|
||||||
|
let planes = plane_keys(held, channel)?;
|
||||||
|
let authors: Vec<PublicKey> = planes.iter().map(|(_, group)| group.pk()).collect();
|
||||||
|
|
||||||
|
if authors.is_empty() || relays.is_empty() || limit == 0 {
|
||||||
|
return Ok(WrapPage {
|
||||||
|
failed: true,
|
||||||
|
..WrapPage::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut walk = Walk::new(relays, window);
|
||||||
|
let mut opened = Vec::new();
|
||||||
|
|
||||||
|
for _ in 0..max_pages {
|
||||||
|
if walk.is_done() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filter = wrap_filter(&authors, walk.region(), limit);
|
||||||
|
let asked: Vec<(usize, RelayUrl)> = walk
|
||||||
|
.live()
|
||||||
|
.map(|index| (index, walk.url(index).clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let answers = join_all(
|
||||||
|
asked
|
||||||
|
.iter()
|
||||||
|
.map(|(_, url)| ingest_page(client, url, &filter)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
for ((index, url), answer) in asked.iter().zip(answers) {
|
||||||
|
if let Err(error) = answer {
|
||||||
|
log::warn!("community: relay {url} did not answer a history page: {error}");
|
||||||
|
walk.reject(*index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let answered = client.database().query(filter).await?;
|
||||||
|
|
||||||
|
for wrap in walk.accept(answered, limit) {
|
||||||
|
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok((stream, rumor)) = cord03::open(&wrap, group, channel, *epoch) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if cache_rumor(client, channel, &stream).await? {
|
||||||
|
opened.push(rumor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(walk.finish(opened))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one filter a page is asked for.
|
||||||
|
fn wrap_filter(authors: &[PublicKey], window: Window, limit: usize) -> Filter {
|
||||||
|
let mut filter = Filter::new()
|
||||||
|
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
|
||||||
|
.authors(authors.iter().copied())
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
if let Some(until_ms) = window.until_ms {
|
||||||
|
filter = filter.until(Timestamp::from_secs(until_ms / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(since_ms) = window.since_ms {
|
||||||
|
filter = filter.since(Timestamp::from_secs(since_ms / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
filter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask one relay for one page and wait until it has answered.
|
||||||
|
async fn ingest_page(client: &Client, url: &RelayUrl, filter: &Filter) -> Result<()> {
|
||||||
|
let relay = match client.relay(url).await? {
|
||||||
|
Some(relay) => relay,
|
||||||
|
None => {
|
||||||
|
client.add_relay(url).and_connect().await?;
|
||||||
|
client
|
||||||
|
.relay(url)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow!("the relay was not added to the pool"))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = history_subscription();
|
||||||
|
let mut notifications = relay.notifications();
|
||||||
|
|
||||||
|
// The SDK closes and unregisters the subscription itself once the relay says
|
||||||
|
// it is done, so nothing has to be unwound here.
|
||||||
|
relay
|
||||||
|
.subscribe(vec![filter.clone()])
|
||||||
|
.with_id(id.clone())
|
||||||
|
.close_on(
|
||||||
|
SubscribeAutoCloseOptions::default()
|
||||||
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||||
|
.timeout(Some(PAGE_TIMEOUT)),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// One deadline for the whole page, so a relay that keeps talking cannot
|
||||||
|
// extend the wait past it.
|
||||||
|
let deadline = Instant::now() + PAGE_TIMEOUT;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||||
|
|
||||||
|
let Some(notification) = within(remaining, notifications.next()).await else {
|
||||||
|
return Err(anyhow!("the relay did not finish the page"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(notification) = notification else {
|
||||||
|
return Err(anyhow!("the relay's notification stream ended"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let RelayNotification::Message { message } = notification else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match *message {
|
||||||
|
RelayMessage::EndOfStoredEvents(ended) => {
|
||||||
|
if ended.as_ref() == &id {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RelayMessage::Closed {
|
||||||
|
subscription_id,
|
||||||
|
message,
|
||||||
|
} if subscription_id.as_ref() == &id && !auth_required(&message) => {
|
||||||
|
return Err(anyhow!("the relay closed the page: {message}"));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a CLOSED reason is the NIP-42 `auth-required` one, the only reason the
|
||||||
|
/// SDK recovers from on its own.
|
||||||
|
fn auth_required(reason: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
MachineReadablePrefix::parse(reason),
|
||||||
|
Some(MachineReadablePrefix::AuthRequired)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A subscription id for one page of one relay, unique so a page's answer is
|
||||||
|
/// never confused with the community's standing subscription or another page's.
|
||||||
|
fn history_subscription() -> SubscriptionId {
|
||||||
|
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
SubscriptionId::new(format!(
|
||||||
|
"concord-history-{}",
|
||||||
|
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Await `future`, giving up after `limit`.
|
||||||
|
async fn within<F>(limit: Duration, future: F) -> Option<F::Output>
|
||||||
|
where
|
||||||
|
F: Future,
|
||||||
|
{
|
||||||
|
let future = std::pin::pin!(future);
|
||||||
|
let deadline = std::pin::pin!(smol::Timer::after(limit));
|
||||||
|
|
||||||
|
match select(future, deadline).await {
|
||||||
|
Either::Left((output, _)) => Some(output),
|
||||||
|
Either::Right(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pass over a channel's history, page by page.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Walk {
|
||||||
|
relays: Vec<Walker>,
|
||||||
|
since_ms: Option<u64>,
|
||||||
|
/// The inclusive upper bound of the next page.
|
||||||
|
cursor: Option<u64>,
|
||||||
|
seen: BTreeSet<EventId>,
|
||||||
|
newest_ms: Option<u64>,
|
||||||
|
oldest_ms: Option<u64>,
|
||||||
|
raw: usize,
|
||||||
|
errors: usize,
|
||||||
|
/// A short page ended the walk.
|
||||||
|
bottom: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One relay's standing in a walk.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Walker {
|
||||||
|
url: RelayUrl,
|
||||||
|
dead: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Walk {
|
||||||
|
fn new(relays: &[RelayUrl], window: Window) -> Self {
|
||||||
|
Self {
|
||||||
|
relays: relays
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|url| Walker { url, dead: false })
|
||||||
|
.collect(),
|
||||||
|
since_ms: window.since_ms,
|
||||||
|
cursor: window.until_ms,
|
||||||
|
seen: BTreeSet::new(),
|
||||||
|
newest_ms: None,
|
||||||
|
oldest_ms: None,
|
||||||
|
raw: 0,
|
||||||
|
errors: 0,
|
||||||
|
bottom: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_done(&self) -> bool {
|
||||||
|
self.bottom || self.relays.iter().all(|walker| walker.dead)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The region the next page asks for.
|
||||||
|
fn region(&self) -> Window {
|
||||||
|
Window {
|
||||||
|
until_ms: self.cursor,
|
||||||
|
since_ms: self.since_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn live(&self) -> impl Iterator<Item = usize> + '_ {
|
||||||
|
(0..self.relays.len()).filter(|&index| !self.relays[index].dead)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, index: usize) -> &RelayUrl {
|
||||||
|
&self.relays[index].url
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject(&mut self, index: usize) {
|
||||||
|
self.relays[index].dead = true;
|
||||||
|
self.errors += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept(&mut self, page: BTreeSet<Event>, limit: usize) -> Vec<Event> {
|
||||||
|
if page.len() < limit {
|
||||||
|
self.bottom = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut oldest: Option<u64> = None;
|
||||||
|
let mut events = Vec::with_capacity(page.len());
|
||||||
|
|
||||||
|
for event in page {
|
||||||
|
let at_ms = event.created_at.as_secs().saturating_mul(1000);
|
||||||
|
self.newest_ms = Some(self.newest_ms.map_or(at_ms, |newest| newest.max(at_ms)));
|
||||||
|
oldest = Some(oldest.map_or(at_ms, |oldest| oldest.min(at_ms)));
|
||||||
|
|
||||||
|
if self.seen.insert(event.id) {
|
||||||
|
self.raw += 1;
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match oldest {
|
||||||
|
Some(oldest) if oldest > 0 => self.cursor = Some(oldest - 1),
|
||||||
|
Some(_) => self.bottom = true,
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(self, opened: Vec<ChatRumor>) -> WrapPage {
|
||||||
|
let swept = self.bottom && self.errors == 0;
|
||||||
|
|
||||||
|
WrapPage {
|
||||||
|
opened,
|
||||||
|
raw: self.raw,
|
||||||
|
newest_ms: self.newest_ms,
|
||||||
|
oldest_ms: self.oldest_ms,
|
||||||
|
exhausted: swept && self.raw > 0,
|
||||||
|
failed: self.errors > 0 || (self.bottom && self.raw == 0),
|
||||||
|
errors: self.errors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::cmp::Reverse;
|
||||||
|
|
||||||
|
use concord::cord03::{build_message, seal_rumor};
|
||||||
|
use concord::derive::channel_group_key;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SECRET: [u8; 32] = [0x07u8; 32];
|
||||||
|
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
|
||||||
|
|
||||||
|
fn serve_page(database: &BTreeSet<Event>, window: Window, limit: usize) -> BTreeSet<Event> {
|
||||||
|
let mut events: Vec<Event> = database
|
||||||
|
.iter()
|
||||||
|
.filter(|event| {
|
||||||
|
let at_ms = event.created_at.as_secs().saturating_mul(1000);
|
||||||
|
window.until_ms.is_none_or(|until| at_ms <= until)
|
||||||
|
&& window.since_ms.is_none_or(|since| at_ms >= since)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
events.sort_by_key(|event| Reverse(event.created_at));
|
||||||
|
events.truncate(limit);
|
||||||
|
events.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relay_url(host: &str) -> RelayUrl {
|
||||||
|
RelayUrl::parse(&format!("wss://{host}.example.com")).expect("parses")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_walk_pages_back_across_a_rekey() {
|
||||||
|
let channel = ChannelId::from_bytes([0x9cu8; 32]);
|
||||||
|
let author = Keys::generate();
|
||||||
|
let held = [(Epoch(0), SECRET), (Epoch(1), NEXT_SECRET)];
|
||||||
|
let planes = plane_keys(&held, &channel).expect("derives");
|
||||||
|
|
||||||
|
// Three messages a second apart: a page boundary falls between each.
|
||||||
|
let base = 1_700_000_000_000;
|
||||||
|
let mut relay: BTreeSet<Event> = BTreeSet::new();
|
||||||
|
|
||||||
|
for (content, secret, epoch, at_ms) in [
|
||||||
|
("before the rekey", &SECRET, Epoch(0), base),
|
||||||
|
("still before", &SECRET, Epoch(0), base + 1_000),
|
||||||
|
("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000),
|
||||||
|
] {
|
||||||
|
let group = channel_group_key(secret, &channel, epoch).expect("derives");
|
||||||
|
let rumor = build_message(
|
||||||
|
author.public_key(),
|
||||||
|
&channel,
|
||||||
|
epoch,
|
||||||
|
content,
|
||||||
|
None,
|
||||||
|
at_ms,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
relay.insert(
|
||||||
|
smol::block_on(seal_rumor(&rumor, &group, &author, false))
|
||||||
|
.expect("seals")
|
||||||
|
.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
|
||||||
|
let mut found = Vec::new();
|
||||||
|
let mut pages = 0;
|
||||||
|
|
||||||
|
while !walk.is_done() && pages < 10 {
|
||||||
|
pages += 1;
|
||||||
|
let page = serve_page(&relay, walk.region(), 2);
|
||||||
|
|
||||||
|
for wrap in walk.accept(page, 2) {
|
||||||
|
let Some((epoch, group)) =
|
||||||
|
planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_, rumor) = cord03::open(&wrap, group, &channel, *epoch).expect("opens");
|
||||||
|
found.push(rumor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
|
||||||
|
|
||||||
|
let contents: Vec<&str> = found.iter().map(|rumor| rumor.content.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
contents,
|
||||||
|
["after the rekey", "still before", "before the rekey"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let page = walk.finish(Vec::new());
|
||||||
|
assert!(page.exhausted);
|
||||||
|
assert!(!page.failed);
|
||||||
|
assert_eq!(page.raw, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_answer_never_seals_the_channel() {
|
||||||
|
let database: BTreeSet<Event> = BTreeSet::new();
|
||||||
|
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
|
||||||
|
|
||||||
|
let page = serve_page(&database, walk.region(), 50);
|
||||||
|
assert!(walk.accept(page, 50).is_empty());
|
||||||
|
|
||||||
|
let page = walk.finish(Vec::new());
|
||||||
|
assert!(page.failed);
|
||||||
|
assert!(!page.exhausted);
|
||||||
|
assert_eq!(page.raw, 0);
|
||||||
|
assert_eq!(page.oldest_ms, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_silent_relay_blocks_the_bottom() {
|
||||||
|
let database: BTreeSet<Event> = BTreeSet::new();
|
||||||
|
let mut walk = Walk::new(
|
||||||
|
&[relay_url("history"), relay_url("archive")],
|
||||||
|
Window::newest(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// One relay answered the empty page; the other never answered at all, so
|
||||||
|
// its share of the region was never read and the walk must not seal.
|
||||||
|
walk.reject(1);
|
||||||
|
let page = serve_page(&database, walk.region(), 50);
|
||||||
|
assert!(walk.accept(page, 50).is_empty());
|
||||||
|
|
||||||
|
let page = walk.finish(Vec::new());
|
||||||
|
assert!(page.failed);
|
||||||
|
assert!(!page.exhausted);
|
||||||
|
assert_eq!(page.errors, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_page_boundary_is_exclusive() {
|
||||||
|
let database = BTreeSet::from([event_at(1_700_000_000_000), event_at(1_700_000_001_000)]);
|
||||||
|
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
|
||||||
|
|
||||||
|
let first = walk.accept(serve_page(&database, walk.region(), 1), 1);
|
||||||
|
let second = walk.accept(serve_page(&database, walk.region(), 1), 1);
|
||||||
|
|
||||||
|
assert_eq!(first.len(), 1);
|
||||||
|
assert_eq!(second.len(), 1);
|
||||||
|
assert_ne!(first[0].id, second[0].id);
|
||||||
|
|
||||||
|
let oldest = second
|
||||||
|
.iter()
|
||||||
|
.map(|event| event.created_at.as_secs() * 1000)
|
||||||
|
.min()
|
||||||
|
.expect("one wrap");
|
||||||
|
assert_eq!(oldest, 1_700_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_at(at_ms: u64) -> Event {
|
||||||
|
let keys = Keys::generate();
|
||||||
|
EventBuilder::new(Kind::TextNote, "page")
|
||||||
|
.custom_created_at(Timestamp::from_secs(at_ms / 1000))
|
||||||
|
.finalize(&keys)
|
||||||
|
.expect("signs")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,16 @@ use anyhow::Result;
|
|||||||
use concord::cord01::KIND_WRAP;
|
use concord::cord01::KIND_WRAP;
|
||||||
pub use concord::cord02::CommunityMetadata;
|
pub use concord::cord02::CommunityMetadata;
|
||||||
pub use concord::cord03::{ChatMessage, ReplyRef};
|
pub use concord::cord03::{ChatMessage, ReplyRef};
|
||||||
use concord::store::CommunityState;
|
use concord::state::CommunityState;
|
||||||
pub use concord::{ChannelId, CommunityId};
|
pub use concord::{ChannelId, CommunityId};
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
|
|
||||||
|
pub mod cache;
|
||||||
mod community;
|
mod community;
|
||||||
|
pub mod history;
|
||||||
mod sync;
|
mod sync;
|
||||||
|
|
||||||
pub use community::*;
|
pub use community::*;
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ use concord::cord04::roles::{Permissions, citation_ok};
|
|||||||
use concord::derive::{
|
use concord::derive::{
|
||||||
channel_group_key, control_group_key, control_signer_group_key, guestbook_group_key,
|
channel_group_key, control_group_key, control_signer_group_key, guestbook_group_key,
|
||||||
};
|
};
|
||||||
use concord::store::{self, CommunityState};
|
use concord::state::{CommunityState, list_entry};
|
||||||
use concord::{ChannelId, CommunityId, Epoch, GroupKey};
|
use concord::{ChannelId, CommunityId, Epoch, GroupKey};
|
||||||
use gpui::AsyncApp;
|
use gpui::AsyncApp;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::UniversalSigner;
|
use state::UniversalSigner;
|
||||||
|
|
||||||
|
use crate::cache;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum PlaneKind {
|
pub enum PlaneKind {
|
||||||
Control(Epoch),
|
Control(Epoch),
|
||||||
@@ -126,7 +128,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let state = CommunityState::from_genesis(&genesis, &editions, at_secs.saturating_mul(1000))?;
|
let state = CommunityState::from_genesis(&genesis, &editions, at_secs.saturating_mul(1000))?;
|
||||||
store::save_state(client, &state).await?;
|
cache::save_state(client, &state).await?;
|
||||||
|
|
||||||
publish_wraps(client, &genesis.wraps, &state.relays).await;
|
publish_wraps(client, &genesis.wraps, &state.relays).await;
|
||||||
|
|
||||||
@@ -199,7 +201,7 @@ where
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let entry = store::list_entry(state, name);
|
let entry = list_entry(state, name);
|
||||||
let list = match held {
|
let list = match held {
|
||||||
Some(held) => held.joined(entry),
|
Some(held) => held.joined(entry),
|
||||||
None => CommunityList::default().joined(entry),
|
None => CommunityList::default().joined(entry),
|
||||||
@@ -282,10 +284,10 @@ pub async fn load(
|
|||||||
) -> Result<Vec<CommunityState>> {
|
) -> Result<Vec<CommunityState>> {
|
||||||
let list = match load_list(client, signer, self_pk).await? {
|
let list = match load_list(client, signer, self_pk).await? {
|
||||||
Some(list) => list,
|
Some(list) => list,
|
||||||
None => return store::load_states(client).await,
|
None => return cache::load_states(client).await,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut held: BTreeMap<CommunityId, CommunityState> = store::load_states(client)
|
let mut held: BTreeMap<CommunityId, CommunityState> = cache::load_states(client)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|state| (state.id, state))
|
.map(|state| (state.id, state))
|
||||||
@@ -314,7 +316,7 @@ pub async fn load(
|
|||||||
None => fresh,
|
None => fresh,
|
||||||
};
|
};
|
||||||
|
|
||||||
store::save_state(client, &state).await?;
|
cache::save_state(client, &state).await?;
|
||||||
held.insert(entry.community_id, state);
|
held.insert(entry.community_id, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +460,7 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snap
|
|||||||
if let Ok((opened, rumor)) =
|
if let Ok((opened, rumor)) =
|
||||||
concord::cord03::open(wrap, &plane.group, &channel, epoch)
|
concord::cord03::open(wrap, &plane.group, &channel, epoch)
|
||||||
{
|
{
|
||||||
store::cache_rumor(client, &channel, &opened).await?;
|
cache::cache_rumor(client, &channel, &opened).await?;
|
||||||
observe(&mut observed, rumor.author, rumor.at_ms);
|
observe(&mut observed, rumor.author, rumor.at_ms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -507,7 +509,7 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snap
|
|||||||
|
|
||||||
let mut state = state.clone();
|
let mut state = state.clone();
|
||||||
state.apply_fold(&control);
|
state.apply_fold(&control);
|
||||||
store::save_state(client, &state).await?;
|
cache::save_state(client, &state).await?;
|
||||||
|
|
||||||
Ok(Some(Snapshot {
|
Ok(Some(Snapshot {
|
||||||
state,
|
state,
|
||||||
@@ -551,14 +553,14 @@ mod tests {
|
|||||||
control_root: None,
|
control_root: None,
|
||||||
control_pks: BTreeMap::from([(0, control_pk)]),
|
control_pks: BTreeMap::from([(0, control_pk)]),
|
||||||
channels: vec![
|
channels: vec![
|
||||||
concord::store::ChannelKeyRef {
|
concord::state::ChannelKeyRef {
|
||||||
id: general,
|
id: general,
|
||||||
name: "general".to_owned(),
|
name: "general".to_owned(),
|
||||||
private: false,
|
private: false,
|
||||||
epoch: Epoch(0),
|
epoch: Epoch(0),
|
||||||
key: None,
|
key: None,
|
||||||
},
|
},
|
||||||
concord::store::ChannelKeyRef {
|
concord::state::ChannelKeyRef {
|
||||||
id: ChannelId::from_bytes([0x9d; 32]),
|
id: ChannelId::from_bytes([0x9d; 32]),
|
||||||
name: "staff".to_owned(),
|
name: "staff".to_owned(),
|
||||||
private: true,
|
private: true,
|
||||||
@@ -569,6 +571,7 @@ mod tests {
|
|||||||
relays: vec![RelayUrl::parse("wss://relay.example").expect("a url")],
|
relays: vec![RelayUrl::parse("wss://relay.example").expect("a url")],
|
||||||
heads: Vec::new(),
|
heads: Vec::new(),
|
||||||
banned: BTreeSet::new(),
|
banned: BTreeSet::new(),
|
||||||
|
cursors: BTreeMap::new(),
|
||||||
dissolved: false,
|
dissolved: false,
|
||||||
added_at_ms: 0,
|
added_at_ms: 0,
|
||||||
};
|
};
|
||||||
@@ -611,7 +614,7 @@ mod tests {
|
|||||||
root_epoch: Epoch(0),
|
root_epoch: Epoch(0),
|
||||||
control_root: Some([0x03; 32]),
|
control_root: Some([0x03; 32]),
|
||||||
control_pks: BTreeMap::from([(0, control_pk)]),
|
control_pks: BTreeMap::from([(0, control_pk)]),
|
||||||
channels: vec![concord::store::ChannelKeyRef {
|
channels: vec![concord::state::ChannelKeyRef {
|
||||||
id: ChannelId::from_bytes([0x9c; 32]),
|
id: ChannelId::from_bytes([0x9c; 32]),
|
||||||
name: "general".to_owned(),
|
name: "general".to_owned(),
|
||||||
private: false,
|
private: false,
|
||||||
@@ -621,6 +624,7 @@ mod tests {
|
|||||||
relays: Vec::new(),
|
relays: Vec::new(),
|
||||||
heads: Vec::new(),
|
heads: Vec::new(),
|
||||||
banned: BTreeSet::new(),
|
banned: BTreeSet::new(),
|
||||||
|
cursors: BTreeMap::new(),
|
||||||
dissolved: false,
|
dissolved: false,
|
||||||
added_at_ms: 1_700_000_000_000,
|
added_at_ms: 1_700_000_000_000,
|
||||||
}
|
}
|
||||||
@@ -806,11 +810,11 @@ mod tests {
|
|||||||
CommunityId::from_bytes([0x42; 32]),
|
CommunityId::from_bytes([0x42; 32]),
|
||||||
Keys::generate().public_key(),
|
Keys::generate().public_key(),
|
||||||
);
|
);
|
||||||
let list = CommunityList::default().joined(store::list_entry(&listed, "coop"));
|
let list = CommunityList::default().joined(list_entry(&listed, "coop"));
|
||||||
store_fragment(&client, &signer, &list).await;
|
store_fragment(&client, &signer, &list).await;
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
store::load_state(&client, &listed.id)
|
cache::load_state(&client, &listed.id)
|
||||||
.await
|
.await
|
||||||
.expect("reads")
|
.expect("reads")
|
||||||
.is_none()
|
.is_none()
|
||||||
@@ -833,7 +837,7 @@ mod tests {
|
|||||||
|
|
||||||
// Discovery writes the document, so the next load is warm.
|
// Discovery writes the document, so the next load is warm.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store::load_state(&client, &listed.id)
|
cache::load_state(&client, &listed.id)
|
||||||
.await
|
.await
|
||||||
.expect("reads")
|
.expect("reads")
|
||||||
.map(|state| state.id),
|
.map(|state| state.id),
|
||||||
@@ -860,9 +864,9 @@ mod tests {
|
|||||||
Keys::generate().public_key(),
|
Keys::generate().public_key(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let list = CommunityList::default().joined(store::list_entry(&listed, "listed"));
|
let list = CommunityList::default().joined(list_entry(&listed, "listed"));
|
||||||
store_fragment(&client, &signer, &list).await;
|
store_fragment(&client, &signer, &list).await;
|
||||||
store::save_state(&client, &local).await.expect("saves");
|
cache::save_state(&client, &local).await.expect("saves");
|
||||||
|
|
||||||
let loaded = load(&client, &signer, keys.public_key())
|
let loaded = load(&client, &signer, keys.public_key())
|
||||||
.await
|
.await
|
||||||
@@ -886,7 +890,7 @@ mod tests {
|
|||||||
CommunityId::from_bytes([0x42; 32]),
|
CommunityId::from_bytes([0x42; 32]),
|
||||||
Keys::generate().public_key(),
|
Keys::generate().public_key(),
|
||||||
);
|
);
|
||||||
store::save_state(&client, &local).await.expect("saves");
|
cache::save_state(&client, &local).await.expect("saves");
|
||||||
|
|
||||||
let list = CommunityList::default().tombstoned(local.id, u64::MAX);
|
let list = CommunityList::default().tombstoned(local.id, u64::MAX);
|
||||||
store_fragment(&client, &signer, &list).await;
|
store_fragment(&client, &signer, &list).await;
|
||||||
|
|||||||
+319
-51
@@ -1,11 +1,17 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use community::{ChannelId, ChatMessage, Community, CommunityEvent};
|
use community::{
|
||||||
|
ChannelId, ChatMessage, Community, CommunityEvent, Intent, LOAD_OLDER_PAGES, TIMELINE_PAGE,
|
||||||
|
Timeline,
|
||||||
|
};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, FollowMode,
|
||||||
IntoElement, ListAlignment, ListState, ParentElement, Render, SharedString, Styled,
|
IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString,
|
||||||
Subscription, Task, WeakEntity, Window, div, list, px,
|
Styled, Subscription, Task, WeakEntity, Window, div, list, px,
|
||||||
};
|
};
|
||||||
|
use nostr_sdk::prelude::EventId;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
@@ -18,6 +24,9 @@ use ui::{IconName, Sizable, WindowExtension, h_flex, v_flex};
|
|||||||
|
|
||||||
mod message;
|
mod message;
|
||||||
|
|
||||||
|
/// How near the top row a scroll has to come before the panel splices older history in.
|
||||||
|
const LOAD_OLDER_THRESHOLD: usize = 20;
|
||||||
|
|
||||||
pub fn init(
|
pub fn init(
|
||||||
community: Entity<Community>,
|
community: Entity<Community>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
@@ -30,25 +39,20 @@ pub fn init(
|
|||||||
pub struct CommunityPanel {
|
pub struct CommunityPanel {
|
||||||
id: SharedString,
|
id: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
|
|
||||||
/// Community
|
/// Community
|
||||||
community: WeakEntity<Community>,
|
community: WeakEntity<Community>,
|
||||||
|
|
||||||
/// The selected channel
|
/// The selected channel
|
||||||
channel: Option<ChannelId>,
|
channel: Option<ChannelId>,
|
||||||
|
|
||||||
/// The selected channel's timeline (oldest first)
|
/// The selected channel's timeline (oldest first)
|
||||||
messages: Vec<ChatMessage>,
|
rows: Vec<ChatMessage>,
|
||||||
|
/// Whether the store holds rows older than `rows`
|
||||||
|
has_more: bool,
|
||||||
|
/// A round or a page read is in flight
|
||||||
|
loading: bool,
|
||||||
/// Message list state
|
/// Message list state
|
||||||
list_state: ListState,
|
list_state: ListState,
|
||||||
|
|
||||||
/// Message input state
|
/// Message input state
|
||||||
input: Entity<TextareaState>,
|
input: Entity<TextareaState>,
|
||||||
|
|
||||||
/// Async operations
|
|
||||||
tasks: Vec<Task<Result<()>>>,
|
|
||||||
|
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
}
|
}
|
||||||
@@ -86,7 +90,8 @@ impl CommunityPanel {
|
|||||||
&community,
|
&community,
|
||||||
window,
|
window,
|
||||||
|_this, _community, event, window, cx| match event {
|
|_this, _community, event, window, cx| match event {
|
||||||
// The fold holds the community, so reload once it is released.
|
// The fold holds the community, and a round caches rows nothing
|
||||||
|
// has read yet, so re-read once either is released.
|
||||||
CommunityEvent::Updated(_) => {
|
CommunityEvent::Updated(_) => {
|
||||||
cx.defer_in(window, |this, window, cx| this.reload(window, cx));
|
cx.defer_in(window, |this, window, cx| this.reload(window, cx));
|
||||||
}
|
}
|
||||||
@@ -107,14 +112,26 @@ impl CommunityPanel {
|
|||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
community: community.downgrade(),
|
community: community.downgrade(),
|
||||||
channel,
|
channel,
|
||||||
messages: Vec::new(),
|
rows: Vec::new(),
|
||||||
|
has_more: false,
|
||||||
|
loading: false,
|
||||||
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
|
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
|
||||||
input,
|
input,
|
||||||
tasks: Vec::new(),
|
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
cx.defer_in(window, |this, window, cx| this.load(window, cx));
|
panel.list_state.set_follow_mode(FollowMode::Tail);
|
||||||
|
panel.list_state.set_scroll_handler(cx.listener(
|
||||||
|
|this, event: &ListScrollEvent, window, cx| {
|
||||||
|
if event.visible_range.start <= LOAD_OLDER_THRESHOLD {
|
||||||
|
this.load_older(window, cx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.defer_in(window, |this, window, cx| {
|
||||||
|
this.load(window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
panel
|
panel
|
||||||
}
|
}
|
||||||
@@ -129,60 +146,104 @@ impl CommunityPanel {
|
|||||||
|
|
||||||
if channel != self.channel {
|
if channel != self.channel {
|
||||||
self.channel = channel;
|
self.channel = channel;
|
||||||
self.messages.clear();
|
self.rows.clear();
|
||||||
self.list_state.reset(0);
|
self.has_more = false;
|
||||||
|
self.loading = false;
|
||||||
|
self.list_state.reset(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
channel
|
channel
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Page the selected channel's history into the cache, then read it back.
|
/// The list's item count: the load-older row, then every message row.
|
||||||
|
fn item_count(&self) -> usize {
|
||||||
|
self.rows.len() + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A timeline read, `before_ms` exclusive, or `None` for the newest rows.
|
||||||
|
fn read(
|
||||||
|
&self,
|
||||||
|
channel: ChannelId,
|
||||||
|
before_ms: Option<u64>,
|
||||||
|
cx: &App,
|
||||||
|
) -> Option<Task<Result<Timeline>>> {
|
||||||
|
self.community
|
||||||
|
.read_with(cx, |community, cx| {
|
||||||
|
community.timeline(&channel, before_ms, TIMELINE_PAGE, cx)
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A channel round, or `None` once the community is gone.
|
||||||
|
fn sync(
|
||||||
|
&self,
|
||||||
|
channel: ChannelId,
|
||||||
|
intent: Intent,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Option<Task<Result<community::Progress>>> {
|
||||||
|
self.community
|
||||||
|
.update(cx, |community, cx| {
|
||||||
|
community.sync_channel(&channel, intent, cx)
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint the selected channel's cache, then catch it up from the relays.
|
||||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let Some(channel) = self.resolve_channel(cx) else {
|
let Some(channel) = self.resolve_channel(cx) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Ok(backfill) = self
|
self.reload(window, cx);
|
||||||
.community
|
self.round(channel, Intent::CatchUp, window, cx);
|
||||||
.read_with(cx, |community, cx| community.backfill(&channel, cx))
|
}
|
||||||
else {
|
|
||||||
|
/// Run a round for `channel`; its completion re-reads the timeline.
|
||||||
|
fn round(
|
||||||
|
&mut self,
|
||||||
|
channel: ChannelId,
|
||||||
|
intent: Intent,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let Some(round) = self.sync(channel, intent, cx) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.loading = true;
|
||||||
if let Err(error) = backfill.await {
|
|
||||||
log::warn!("community panel: backfill failed: {error}");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
|
cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
||||||
|
let result = round.await;
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.loading = false;
|
||||||
|
|
||||||
|
if let Err(error) = result {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(error.to_string()).autohide(false),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
})
|
||||||
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the timeline with the selected channel's folded messages.
|
/// Read the newest page and fold it into what is on screen.
|
||||||
fn reload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn reload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let Some(channel) = self.resolve_channel(cx) else {
|
let Some(channel) = self.resolve_channel(cx) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Ok(messages) = self
|
let Some(timeline) = self.read(channel, None, cx) else {
|
||||||
.community
|
|
||||||
.read_with(cx, |community, cx| community.messages(&channel, cx))
|
|
||||||
else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
||||||
match messages.await {
|
match timeline.await {
|
||||||
Ok(messages) => {
|
Ok(timeline) => this.update(cx, |this, cx| this.apply(channel, timeline, cx))?,
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.messages = messages;
|
|
||||||
this.list_state.reset(this.messages.len());
|
|
||||||
this.list_state.scroll_to_end();
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
this.update_in(cx, |_this, window, cx| {
|
this.update_in(cx, |_this, window, cx| {
|
||||||
window.push_notification(
|
window.push_notification(
|
||||||
@@ -194,7 +255,183 @@ impl CommunityPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splice the page of history above the oldest row on screen.
|
||||||
|
fn load_older(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.loading || !self.has_more {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(channel) = self.channel else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(before_ms) = self
|
||||||
|
.rows
|
||||||
|
.first()
|
||||||
|
.map(|message| message.at_ms.saturating_sub(1))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(page) = self.read(channel, Some(before_ms), cx) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.loading = true;
|
||||||
|
|
||||||
|
cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
||||||
|
let timeline = match page.await {
|
||||||
|
Ok(timeline) => timeline,
|
||||||
|
Err(error) => {
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.loading = false;
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(error.to_string()).autohide(false),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let swept = this.update(cx, |this, cx| {
|
||||||
|
this.prepend(channel, timeline, cx);
|
||||||
|
!this.has_more
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !swept {
|
||||||
|
this.update(cx, |this, _cx| this.loading = false)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let round = this.update(cx, |this, cx| {
|
||||||
|
this.sync(
|
||||||
|
channel,
|
||||||
|
Intent::Older {
|
||||||
|
pages: LOAD_OLDER_PAGES,
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let Some(round) = round else {
|
||||||
|
this.update(cx, |this, _cx| this.loading = false)?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
match round.await {
|
||||||
|
Ok(_) => {
|
||||||
|
let page =
|
||||||
|
this.update(cx, |this, cx| this.read(channel, Some(before_ms), cx))?;
|
||||||
|
|
||||||
|
if let Some(page) = page {
|
||||||
|
match page.await {
|
||||||
|
Ok(timeline) => {
|
||||||
|
this.update(cx, |this, cx| this.prepend(channel, timeline, cx))?
|
||||||
|
}
|
||||||
|
Err(error) => this.update_in(cx, |_this, window, cx| {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(error.to_string()).autohide(false),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.update(cx, |this, _cx| this.loading = false)?;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.loading = false;
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(error.to_string()).autohide(false),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold a freshly read window into the rows on screen.
|
||||||
|
fn apply(&mut self, channel: ChannelId, timeline: Timeline, cx: &mut Context<Self>) {
|
||||||
|
if self.channel != Some(channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Timeline { messages, has_more } = timeline;
|
||||||
|
|
||||||
|
let connected = self
|
||||||
|
.rows
|
||||||
|
.last()
|
||||||
|
.is_some_and(|last| messages.iter().any(|message| message.id == last.id));
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
self.rows = messages;
|
||||||
|
self.has_more = has_more;
|
||||||
|
self.list_state.reset(self.item_count());
|
||||||
|
cx.notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut index: HashMap<EventId, usize> = self
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(ix, message)| (message.id, ix))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut added = Vec::new();
|
||||||
|
|
||||||
|
for message in messages {
|
||||||
|
match index.get(&message.id).copied() {
|
||||||
|
Some(ix) => self.rows[ix] = message,
|
||||||
|
None => {
|
||||||
|
index.insert(message.id, self.rows.len() + added.len());
|
||||||
|
added.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !added.is_empty() {
|
||||||
|
let at = self.item_count();
|
||||||
|
let count = added.len();
|
||||||
|
self.rows.extend(added);
|
||||||
|
self.list_state.splice(at..at, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splice older rows in above what is on screen.
|
||||||
|
fn prepend(&mut self, channel: ChannelId, timeline: Timeline, cx: &mut Context<Self>) {
|
||||||
|
if self.channel != Some(channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let known: HashSet<EventId> = self.rows.iter().map(|message| message.id).collect();
|
||||||
|
let added: Vec<ChatMessage> = timeline
|
||||||
|
.messages
|
||||||
|
.into_iter()
|
||||||
|
.filter(|message| !known.contains(&message.id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
self.has_more = timeline.has_more;
|
||||||
|
|
||||||
|
if !added.is_empty() {
|
||||||
|
let count = added.len();
|
||||||
|
self.rows.splice(0..0, added);
|
||||||
|
self.list_state.splice(1..1, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -224,7 +461,7 @@ impl CommunityPanel {
|
|||||||
input.set_value("", window, cx);
|
input.set_value("", window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
||||||
match send.await {
|
match send.await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
|
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
|
||||||
@@ -240,7 +477,33 @@ impl CommunityPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row at index 0: the affordance that pages older history in.
|
||||||
|
fn render_older(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
if !self.has_more {
|
||||||
|
return div().into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.w_full()
|
||||||
|
.justify_center()
|
||||||
|
.py_2()
|
||||||
|
.child(
|
||||||
|
Button::new("load-older")
|
||||||
|
.label(if self.loading {
|
||||||
|
"Loading earlier messages…"
|
||||||
|
} else {
|
||||||
|
"Load earlier messages"
|
||||||
|
})
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.loading(self.loading)
|
||||||
|
.on_click(cx.listener(|this, _event, window, cx| this.load_older(window, cx))),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_message(
|
fn render_message(
|
||||||
@@ -249,9 +512,14 @@ impl CommunityPanel {
|
|||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let Some(message) = self.messages.get(ix) else {
|
if ix == 0 {
|
||||||
|
return self.render_older(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(message) = self.rows.get(ix - 1) else {
|
||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
};
|
};
|
||||||
|
|
||||||
message::render(ix, message, cx)
|
message::render(ix, message, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +591,7 @@ impl Render for CommunityPanel {
|
|||||||
.min_h_0()
|
.min_h_0()
|
||||||
.relative()
|
.relative()
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
if self.messages.is_empty() {
|
if self.rows.is_empty() {
|
||||||
this.child(
|
this.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
|
|||||||
@@ -21,5 +21,4 @@ anyhow.workspace = true
|
|||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
nostr-memory.workspace = true
|
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
|
|||||||
@@ -781,7 +781,7 @@ mod tests {
|
|||||||
use crate::cord04::pins;
|
use crate::cord04::pins;
|
||||||
use crate::cord04::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope};
|
use crate::cord04::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope};
|
||||||
use crate::derive::{channel_group_key, grant_locator};
|
use crate::derive::{channel_group_key, grant_locator};
|
||||||
use crate::store::CommunityState;
|
use crate::state::CommunityState;
|
||||||
use crate::{Extra, RoleId};
|
use crate::{Extra, RoleId};
|
||||||
|
|
||||||
const AT: u64 = 1_700_000_000;
|
const AT: u64 = 1_700_000_000;
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ pub const KIND_TIMER_NOTICE: u16 = 1740;
|
|||||||
pub const KIND_WEBXDC: u16 = 3310;
|
pub const KIND_WEBXDC: u16 = 3310;
|
||||||
pub const KIND_TYPING: u16 = 23311;
|
pub const KIND_TYPING: u16 = 23311;
|
||||||
|
|
||||||
|
pub const ROW_KINDS: [u16; 4] = [KIND_MESSAGE, KIND_FILE, KIND_COMMENT, KIND_TIMER_NOTICE];
|
||||||
|
pub const SIDE_KINDS: [u16; 3] = [KIND_DELETE, KIND_REACTION, KIND_EDIT];
|
||||||
|
|
||||||
const TAG_QUOTE: &str = "q";
|
const TAG_QUOTE: &str = "q";
|
||||||
const TAG_TARGET: &str = "e";
|
const TAG_TARGET: &str = "e";
|
||||||
const TAG_TARGET_KIND: &str = "k";
|
const TAG_TARGET_KIND: &str = "k";
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ mod cords;
|
|||||||
mod types;
|
mod types;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub mod store;
|
pub mod state;
|
||||||
|
|
||||||
pub use cords::{cord01, cord02, cord03, cord04, cord05, cord06};
|
pub use cords::{cord01, cord02, cord03, cord04, cord05, cord06};
|
||||||
|
pub(crate) use types::Extra;
|
||||||
pub use types::{ChannelId, CommunityId, Epoch, RoleId};
|
pub use types::{ChannelId, CommunityId, Epoch, RoleId};
|
||||||
pub use utils::derive::{self, GroupKey};
|
pub use utils::derive::{self, GroupKey};
|
||||||
|
|
||||||
pub(crate) use types::Extra;
|
|
||||||
pub(crate) use utils::{decode_hex_32, decode_hex_lower, fill_random, random_32};
|
pub(crate) use utils::{decode_hex_32, decode_hex_lower, fill_random, random_32};
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::cord02::list::{CommunityListEntry, JoinMaterial};
|
||||||
|
use crate::cord02::{
|
||||||
|
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
|
||||||
|
};
|
||||||
|
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
|
||||||
|
use crate::cord05::ChannelGrant;
|
||||||
|
use crate::derive::control_signer_group_key;
|
||||||
|
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
|
||||||
|
|
||||||
|
/// The `concord/` namespace for locally-keyed documents.
|
||||||
|
pub const STATE_PREFIX: &str = "concord/";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ChannelKeyRef {
|
||||||
|
pub id: ChannelId,
|
||||||
|
pub name: String,
|
||||||
|
pub private: bool,
|
||||||
|
pub epoch: Epoch,
|
||||||
|
/// The channel's read secret when the member was granted it.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub key: Option<[u8; 32]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far a channel's history sync has reached, in epoch milliseconds.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ChannelCursor {
|
||||||
|
/// The newest wrap ingested, so a live subscription knows where to resume.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub newest_ms: Option<u64>,
|
||||||
|
/// The oldest wrap paged back to, so the next round resumes below it.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub oldest_ms: Option<u64>,
|
||||||
|
/// History verifiably swept to the bottom.
|
||||||
|
#[serde(default)]
|
||||||
|
pub exhausted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelCursor {
|
||||||
|
pub fn merge(self, round: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
newest_ms: later(self.newest_ms, round.newest_ms),
|
||||||
|
oldest_ms: earlier(self.oldest_ms, round.oldest_ms),
|
||||||
|
exhausted: self.exhausted || round.exhausted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn later(held: Option<u64>, round: Option<u64>) -> Option<u64> {
|
||||||
|
match (held, round) {
|
||||||
|
(Some(held), Some(round)) => Some(held.max(round)),
|
||||||
|
(held, None) => held,
|
||||||
|
(None, round) => round,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn earlier(held: Option<u64>, round: Option<u64>) -> Option<u64> {
|
||||||
|
match (held, round) {
|
||||||
|
(Some(held), Some(round)) => Some(held.min(round)),
|
||||||
|
(held, None) => held,
|
||||||
|
(None, round) => round,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One local document per community, keyed by `concord/<community_id>`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CommunityState {
|
||||||
|
pub id: CommunityId,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub owner: PublicKey,
|
||||||
|
pub owner_salt: [u8; 32],
|
||||||
|
pub community_root: [u8; 32],
|
||||||
|
pub root_epoch: Epoch,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub control_root: Option<[u8; 32]>,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub control_pks: BTreeMap<u64, PublicKey>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub channels: Vec<ChannelKeyRef>,
|
||||||
|
pub relays: Vec<RelayUrl>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub heads: Vec<EntityHead>,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||||
|
pub banned: BTreeSet<PublicKey>,
|
||||||
|
/// Where each channel's history sync has reached.
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub cursors: BTreeMap<ChannelId, ChannelCursor>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dissolved: bool,
|
||||||
|
pub added_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommunityState {
|
||||||
|
pub fn from_genesis(
|
||||||
|
genesis: &CommunityGenesis,
|
||||||
|
editions: &[ParsedEdition],
|
||||||
|
added_at_ms: u64,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let mut channels = Vec::new();
|
||||||
|
let mut heads = Vec::with_capacity(editions.len());
|
||||||
|
let mut relays = Vec::new();
|
||||||
|
let mut name = None;
|
||||||
|
|
||||||
|
for edition in editions {
|
||||||
|
heads.push(EntityHead {
|
||||||
|
entity: edition.entity,
|
||||||
|
version: edition.version,
|
||||||
|
self_hash: edition.self_hash,
|
||||||
|
rumor_id: edition.rumor_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
match edition.subkind.as_str() {
|
||||||
|
vsk::COMMUNITY_METADATA => {
|
||||||
|
let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?;
|
||||||
|
relays.extend(
|
||||||
|
metadata
|
||||||
|
.relays
|
||||||
|
.iter()
|
||||||
|
.filter_map(|relay| RelayUrl::parse(relay).ok()),
|
||||||
|
);
|
||||||
|
name = label(&metadata.name);
|
||||||
|
}
|
||||||
|
vsk::CHANNEL_METADATA => {
|
||||||
|
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
|
||||||
|
channels.push(ChannelKeyRef {
|
||||||
|
id: ChannelId::from_bytes(edition.entity),
|
||||||
|
name: metadata.name,
|
||||||
|
private: metadata.private,
|
||||||
|
epoch: ROOT_EPOCH,
|
||||||
|
key: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let control_pks = BTreeMap::from([(
|
||||||
|
ROOT_EPOCH.0,
|
||||||
|
control_signer_group_key(
|
||||||
|
&genesis.control_root,
|
||||||
|
&genesis.identity.community_id,
|
||||||
|
ROOT_EPOCH,
|
||||||
|
)?
|
||||||
|
.pk(),
|
||||||
|
)]);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
id: genesis.identity.community_id,
|
||||||
|
name,
|
||||||
|
owner: genesis.identity.owner,
|
||||||
|
owner_salt: genesis.identity.owner_salt,
|
||||||
|
community_root: genesis.community_root,
|
||||||
|
root_epoch: ROOT_EPOCH,
|
||||||
|
control_root: Some(genesis.control_root),
|
||||||
|
control_pks,
|
||||||
|
channels,
|
||||||
|
relays,
|
||||||
|
heads,
|
||||||
|
banned: BTreeSet::new(),
|
||||||
|
cursors: BTreeMap::new(),
|
||||||
|
dissolved: false,
|
||||||
|
added_at_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_join_material(material: &JoinMaterial, added_at_ms: u64) -> Result<Self> {
|
||||||
|
let control_pks = match material.control_pk {
|
||||||
|
Some(address) => BTreeMap::from([(material.root_epoch.0, address)]),
|
||||||
|
None => BTreeMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut channels = Vec::with_capacity(material.channels.len());
|
||||||
|
for grant in &material.channels {
|
||||||
|
let key = match &grant.key {
|
||||||
|
Some(key) => Some(decode_hex_32(key)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
channels.push(ChannelKeyRef {
|
||||||
|
id: grant.id,
|
||||||
|
name: grant.name.clone(),
|
||||||
|
private: key.is_some(),
|
||||||
|
epoch: grant.epoch,
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
id: material.community_id,
|
||||||
|
name: label(&material.name),
|
||||||
|
owner: material.owner,
|
||||||
|
owner_salt: decode_hex_32(&material.owner_salt)?,
|
||||||
|
community_root: decode_hex_32(&material.community_root)?,
|
||||||
|
root_epoch: material.root_epoch,
|
||||||
|
control_root: match &material.control_root {
|
||||||
|
Some(root) => Some(decode_hex_32(root)?),
|
||||||
|
None => None,
|
||||||
|
},
|
||||||
|
control_pks,
|
||||||
|
channels,
|
||||||
|
relays: material
|
||||||
|
.relays
|
||||||
|
.iter()
|
||||||
|
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||||
|
.collect(),
|
||||||
|
heads: Vec::new(),
|
||||||
|
banned: BTreeSet::new(),
|
||||||
|
cursors: BTreeMap::new(),
|
||||||
|
dissolved: false,
|
||||||
|
added_at_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn identifier(&self) -> String {
|
||||||
|
state_identifier(&self.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn floors(&self) -> Floors {
|
||||||
|
self.heads
|
||||||
|
.iter()
|
||||||
|
.map(|head| (head.entity, head.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_fold(&mut self, fold: &ControlFold) {
|
||||||
|
self.heads = fold.floors.values().cloned().collect();
|
||||||
|
self.banned = fold.banned.clone();
|
||||||
|
|
||||||
|
if let Some(community) = &fold.community {
|
||||||
|
self.relays = community
|
||||||
|
.relays
|
||||||
|
.iter()
|
||||||
|
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if let Some(name) = label(&community.name) {
|
||||||
|
self.name = Some(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (id, metadata) in &fold.channels {
|
||||||
|
if metadata.deleted.unwrap_or(false) {
|
||||||
|
self.channels.retain(|channel| channel.id != *id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.channels.iter_mut().find(|channel| channel.id == *id) {
|
||||||
|
Some(channel) => {
|
||||||
|
channel.name = metadata.name.clone();
|
||||||
|
|
||||||
|
if !metadata.private {
|
||||||
|
channel.private = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None if !metadata.private => self.channels.push(ChannelKeyRef {
|
||||||
|
id: *id,
|
||||||
|
name: metadata.name.clone(),
|
||||||
|
private: false,
|
||||||
|
epoch: self.root_epoch,
|
||||||
|
key: None,
|
||||||
|
}),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_entry(state: &CommunityState, name: &str) -> CommunityListEntry {
|
||||||
|
let material = JoinMaterial {
|
||||||
|
community_id: state.id,
|
||||||
|
owner: state.owner,
|
||||||
|
owner_salt: HEXLOWER.encode(&state.owner_salt),
|
||||||
|
community_root: HEXLOWER.encode(&state.community_root),
|
||||||
|
root_epoch: state.root_epoch,
|
||||||
|
control_pk: state.control_pks.get(&state.root_epoch.0).copied(),
|
||||||
|
control_root: state.control_root.map(|root| HEXLOWER.encode(&root)),
|
||||||
|
channels: state
|
||||||
|
.channels
|
||||||
|
.iter()
|
||||||
|
.map(|channel| ChannelGrant {
|
||||||
|
id: channel.id,
|
||||||
|
key: channel.key.map(|key| HEXLOWER.encode(&key)),
|
||||||
|
epoch: channel.epoch,
|
||||||
|
name: channel.name.clone(),
|
||||||
|
extra: Extra::default(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
relays: state.relays.iter().map(RelayUrl::to_string).collect(),
|
||||||
|
name: name.to_owned(),
|
||||||
|
extra: Extra::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
CommunityListEntry {
|
||||||
|
community_id: state.id,
|
||||||
|
seed: material.clone(),
|
||||||
|
current: material,
|
||||||
|
added_at: state.added_at_ms,
|
||||||
|
extra: Extra::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(name: &str) -> Option<String> {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
(!trimmed.is_empty()).then(|| trimmed.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local document key a community's state is stored under.
|
||||||
|
pub fn state_identifier(id: &CommunityId) -> String {
|
||||||
|
format!("{STATE_PREFIX}{}", id.to_hex())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cursor_merge_only_moves_forward_and_never_seals() {
|
||||||
|
let held = ChannelCursor {
|
||||||
|
newest_ms: Some(1_000),
|
||||||
|
oldest_ms: Some(5_000),
|
||||||
|
exhausted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// An incomplete round reports nothing and moves neither bound.
|
||||||
|
assert_eq!(held.merge(ChannelCursor::default()), held);
|
||||||
|
|
||||||
|
let merged = held.merge(ChannelCursor {
|
||||||
|
newest_ms: Some(2_000),
|
||||||
|
oldest_ms: Some(3_000),
|
||||||
|
exhausted: true,
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
merged,
|
||||||
|
ChannelCursor {
|
||||||
|
newest_ms: Some(2_000),
|
||||||
|
oldest_ms: Some(3_000),
|
||||||
|
exhausted: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// A later round that learned less cannot walk either bound back.
|
||||||
|
assert_eq!(
|
||||||
|
merged.merge(ChannelCursor {
|
||||||
|
newest_ms: Some(1_500),
|
||||||
|
oldest_ms: Some(4_000),
|
||||||
|
exhausted: false,
|
||||||
|
}),
|
||||||
|
merged
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
|
||||||
|
let owner = Keys::generate().public_key();
|
||||||
|
let control_pk = Keys::generate().public_key();
|
||||||
|
let staff = ChannelId::from_bytes([0x9c; 32]);
|
||||||
|
let general = ChannelId::from_bytes([0x9d; 32]);
|
||||||
|
|
||||||
|
let material = JoinMaterial {
|
||||||
|
community_id: CommunityId::from_bytes([0x42; 32]),
|
||||||
|
owner,
|
||||||
|
owner_salt: "01".repeat(32),
|
||||||
|
community_root: "02".repeat(32),
|
||||||
|
root_epoch: Epoch(3),
|
||||||
|
control_pk: Some(control_pk),
|
||||||
|
control_root: Some("03".repeat(32)),
|
||||||
|
channels: vec![
|
||||||
|
ChannelGrant {
|
||||||
|
id: staff,
|
||||||
|
key: Some("04".repeat(32)),
|
||||||
|
epoch: Epoch(2),
|
||||||
|
name: "staff".to_owned(),
|
||||||
|
extra: Extra::default(),
|
||||||
|
},
|
||||||
|
ChannelGrant {
|
||||||
|
id: general,
|
||||||
|
key: None,
|
||||||
|
epoch: Epoch(0),
|
||||||
|
name: "general".to_owned(),
|
||||||
|
extra: Extra::default(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relays: vec!["wss://relay.example".to_owned()],
|
||||||
|
name: "Room".to_owned(),
|
||||||
|
extra: Extra::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = CommunityState::from_join_material(&material, 7).expect("materializes");
|
||||||
|
|
||||||
|
assert_eq!(state.id, material.community_id);
|
||||||
|
assert_eq!(state.owner, owner);
|
||||||
|
assert_eq!(state.owner_salt, [0x01; 32]);
|
||||||
|
assert_eq!(state.community_root, [0x02; 32]);
|
||||||
|
assert_eq!(state.root_epoch, Epoch(3));
|
||||||
|
assert_eq!(state.control_root, Some([0x03; 32]));
|
||||||
|
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
|
||||||
|
assert!(
|
||||||
|
state.heads.is_empty(),
|
||||||
|
"the first control fold fills the heads"
|
||||||
|
);
|
||||||
|
assert!(state.banned.is_empty());
|
||||||
|
assert!(!state.dissolved);
|
||||||
|
assert_eq!(state.relays.len(), 1);
|
||||||
|
assert_eq!(state.added_at_ms, 7);
|
||||||
|
|
||||||
|
// A granted key lands on the channel and makes it private; a grant with
|
||||||
|
// no key is a public channel.
|
||||||
|
let granted = state
|
||||||
|
.channels
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id == staff)
|
||||||
|
.expect("staff");
|
||||||
|
assert!(granted.private);
|
||||||
|
assert_eq!(granted.key, Some([0x04; 32]));
|
||||||
|
assert_eq!(granted.epoch, Epoch(2));
|
||||||
|
assert_eq!(granted.name, "staff");
|
||||||
|
|
||||||
|
let public = state
|
||||||
|
.channels
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id == general)
|
||||||
|
.expect("general");
|
||||||
|
assert!(!public.private);
|
||||||
|
assert_eq!(public.key, None);
|
||||||
|
|
||||||
|
// A member who is not staff carries no control_root, but reading needs no
|
||||||
|
// secret: the address rides in the material either way.
|
||||||
|
let mut member = material.clone();
|
||||||
|
member.control_root = None;
|
||||||
|
let state = CommunityState::from_join_material(&member, 7).expect("materializes");
|
||||||
|
assert_eq!(state.control_root, None);
|
||||||
|
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,758 +0,0 @@
|
|||||||
use std::cmp::Reverse;
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
|
||||||
use data_encoding::HEXLOWER;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
|
|
||||||
use crate::cord02::list::{CommunityListEntry, JoinMaterial};
|
|
||||||
use crate::cord02::{
|
|
||||||
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
|
|
||||||
};
|
|
||||||
use crate::cord03::{self, ChatRumor, plane_keys};
|
|
||||||
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
|
|
||||||
use crate::cord05::ChannelGrant;
|
|
||||||
use crate::derive::control_signer_group_key;
|
|
||||||
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, decode_hex_32};
|
|
||||||
|
|
||||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
|
||||||
|
|
||||||
const MAX_PAGES: usize = 8;
|
|
||||||
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: &str = "k";
|
|
||||||
/// The `concord/` namespace for locally-keyed documents.
|
|
||||||
pub const STATE_PREFIX: &str = "concord/";
|
|
||||||
|
|
||||||
/// 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<bool> {
|
|
||||||
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, [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<usize> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn query_rumors(
|
|
||||||
client: &Client,
|
|
||||||
channel: &ChannelId,
|
|
||||||
until: Option<Timestamp>,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<UnsignedEvent>> {
|
|
||||||
let mut filter = Filter::new()
|
|
||||||
.kind(Kind::ApplicationSpecificData)
|
|
||||||
.custom_tag(MARK_TAG, MARK_VALUE)
|
|
||||||
.custom_tag(CHANNEL_TAG, channel.to_hex());
|
|
||||||
|
|
||||||
if let Some(until) = until {
|
|
||||||
filter = filter.until(until);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut newest: BTreeMap<String, Event> = 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<Event> = 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct ChannelKeyRef {
|
|
||||||
pub id: ChannelId,
|
|
||||||
pub name: String,
|
|
||||||
pub private: bool,
|
|
||||||
pub epoch: Epoch,
|
|
||||||
/// The channel's read secret when the member was granted it.
|
|
||||||
///
|
|
||||||
/// A public channel derives its key from the `community_root` and carries none.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub key: Option<[u8; 32]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One local document per community, keyed by `concord/<community_id>`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct CommunityState {
|
|
||||||
pub id: CommunityId,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub owner: PublicKey,
|
|
||||||
pub owner_salt: [u8; 32],
|
|
||||||
pub community_root: [u8; 32],
|
|
||||||
pub root_epoch: Epoch,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub control_root: Option<[u8; 32]>,
|
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
||||||
pub control_pks: BTreeMap<u64, PublicKey>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub channels: Vec<ChannelKeyRef>,
|
|
||||||
pub relays: Vec<RelayUrl>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub heads: Vec<EntityHead>,
|
|
||||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
|
||||||
pub banned: BTreeSet<PublicKey>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub dissolved: bool,
|
|
||||||
pub added_at_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommunityState {
|
|
||||||
pub fn from_genesis(
|
|
||||||
genesis: &CommunityGenesis,
|
|
||||||
editions: &[ParsedEdition],
|
|
||||||
added_at_ms: u64,
|
|
||||||
) -> Result<Self> {
|
|
||||||
let mut channels = Vec::new();
|
|
||||||
let mut heads = Vec::with_capacity(editions.len());
|
|
||||||
let mut relays = Vec::new();
|
|
||||||
let mut name = None;
|
|
||||||
|
|
||||||
for edition in editions {
|
|
||||||
heads.push(EntityHead {
|
|
||||||
entity: edition.entity,
|
|
||||||
version: edition.version,
|
|
||||||
self_hash: edition.self_hash,
|
|
||||||
rumor_id: edition.rumor_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
match edition.subkind.as_str() {
|
|
||||||
vsk::COMMUNITY_METADATA => {
|
|
||||||
let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?;
|
|
||||||
relays.extend(
|
|
||||||
metadata
|
|
||||||
.relays
|
|
||||||
.iter()
|
|
||||||
.filter_map(|relay| RelayUrl::parse(relay).ok()),
|
|
||||||
);
|
|
||||||
name = label(&metadata.name);
|
|
||||||
}
|
|
||||||
vsk::CHANNEL_METADATA => {
|
|
||||||
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
|
|
||||||
channels.push(ChannelKeyRef {
|
|
||||||
id: ChannelId::from_bytes(edition.entity),
|
|
||||||
name: metadata.name,
|
|
||||||
private: metadata.private,
|
|
||||||
epoch: ROOT_EPOCH,
|
|
||||||
key: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let control_pks = BTreeMap::from([(
|
|
||||||
ROOT_EPOCH.0,
|
|
||||||
control_signer_group_key(
|
|
||||||
&genesis.control_root,
|
|
||||||
&genesis.identity.community_id,
|
|
||||||
ROOT_EPOCH,
|
|
||||||
)?
|
|
||||||
.pk(),
|
|
||||||
)]);
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
id: genesis.identity.community_id,
|
|
||||||
name,
|
|
||||||
owner: genesis.identity.owner,
|
|
||||||
owner_salt: genesis.identity.owner_salt,
|
|
||||||
community_root: genesis.community_root,
|
|
||||||
root_epoch: ROOT_EPOCH,
|
|
||||||
control_root: Some(genesis.control_root),
|
|
||||||
control_pks,
|
|
||||||
channels,
|
|
||||||
relays,
|
|
||||||
heads,
|
|
||||||
banned: BTreeSet::new(),
|
|
||||||
dissolved: false,
|
|
||||||
added_at_ms,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_join_material(material: &JoinMaterial, added_at_ms: u64) -> Result<Self> {
|
|
||||||
let control_pks = match material.control_pk {
|
|
||||||
Some(address) => BTreeMap::from([(material.root_epoch.0, address)]),
|
|
||||||
None => BTreeMap::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut channels = Vec::with_capacity(material.channels.len());
|
|
||||||
for grant in &material.channels {
|
|
||||||
let key = match &grant.key {
|
|
||||||
Some(key) => Some(decode_hex_32(key)?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
channels.push(ChannelKeyRef {
|
|
||||||
id: grant.id,
|
|
||||||
name: grant.name.clone(),
|
|
||||||
private: key.is_some(),
|
|
||||||
epoch: grant.epoch,
|
|
||||||
key,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
id: material.community_id,
|
|
||||||
name: label(&material.name),
|
|
||||||
owner: material.owner,
|
|
||||||
owner_salt: decode_hex_32(&material.owner_salt)?,
|
|
||||||
community_root: decode_hex_32(&material.community_root)?,
|
|
||||||
root_epoch: material.root_epoch,
|
|
||||||
control_root: match &material.control_root {
|
|
||||||
Some(root) => Some(decode_hex_32(root)?),
|
|
||||||
None => None,
|
|
||||||
},
|
|
||||||
control_pks,
|
|
||||||
channels,
|
|
||||||
relays: material
|
|
||||||
.relays
|
|
||||||
.iter()
|
|
||||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
|
||||||
.collect(),
|
|
||||||
heads: Vec::new(),
|
|
||||||
banned: BTreeSet::new(),
|
|
||||||
dissolved: false,
|
|
||||||
added_at_ms,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn identifier(&self) -> String {
|
|
||||||
state_identifier(&self.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn floors(&self) -> Floors {
|
|
||||||
self.heads
|
|
||||||
.iter()
|
|
||||||
.map(|head| (head.entity, head.clone()))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply_fold(&mut self, fold: &ControlFold) {
|
|
||||||
self.heads = fold.floors.values().cloned().collect();
|
|
||||||
self.banned = fold.banned.clone();
|
|
||||||
|
|
||||||
if let Some(community) = &fold.community {
|
|
||||||
self.relays = community
|
|
||||||
.relays
|
|
||||||
.iter()
|
|
||||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if let Some(name) = label(&community.name) {
|
|
||||||
self.name = Some(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (id, metadata) in &fold.channels {
|
|
||||||
if metadata.deleted.unwrap_or(false) {
|
|
||||||
self.channels.retain(|channel| channel.id != *id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.channels.iter_mut().find(|channel| channel.id == *id) {
|
|
||||||
Some(channel) => {
|
|
||||||
channel.name = metadata.name.clone();
|
|
||||||
|
|
||||||
if !metadata.private {
|
|
||||||
channel.private = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None if !metadata.private => self.channels.push(ChannelKeyRef {
|
|
||||||
id: *id,
|
|
||||||
name: metadata.name.clone(),
|
|
||||||
private: false,
|
|
||||||
epoch: self.root_epoch,
|
|
||||||
key: None,
|
|
||||||
}),
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_entry(state: &CommunityState, name: &str) -> CommunityListEntry {
|
|
||||||
let material = JoinMaterial {
|
|
||||||
community_id: state.id,
|
|
||||||
owner: state.owner,
|
|
||||||
owner_salt: HEXLOWER.encode(&state.owner_salt),
|
|
||||||
community_root: HEXLOWER.encode(&state.community_root),
|
|
||||||
root_epoch: state.root_epoch,
|
|
||||||
control_pk: state.control_pks.get(&state.root_epoch.0).copied(),
|
|
||||||
control_root: state.control_root.map(|root| HEXLOWER.encode(&root)),
|
|
||||||
channels: state
|
|
||||||
.channels
|
|
||||||
.iter()
|
|
||||||
.map(|channel| ChannelGrant {
|
|
||||||
id: channel.id,
|
|
||||||
key: channel.key.map(|key| HEXLOWER.encode(&key)),
|
|
||||||
epoch: channel.epoch,
|
|
||||||
name: channel.name.clone(),
|
|
||||||
extra: Extra::default(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
relays: state.relays.iter().map(RelayUrl::to_string).collect(),
|
|
||||||
name: name.to_owned(),
|
|
||||||
extra: Extra::default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
CommunityListEntry {
|
|
||||||
community_id: state.id,
|
|
||||||
seed: material.clone(),
|
|
||||||
current: material,
|
|
||||||
added_at: state.added_at_ms,
|
|
||||||
extra: Extra::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn label(name: &str) -> Option<String> {
|
|
||||||
let trimmed = name.trim();
|
|
||||||
(!trimmed.is_empty()).then(|| trimmed.to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_identifier(id: &CommunityId) -> String {
|
|
||||||
format!("{STATE_PREFIX}{}", id.to_hex())
|
|
||||||
}
|
|
||||||
|
|
||||||
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<Option<CommunityState>> {
|
|
||||||
let filter = Filter::new()
|
|
||||||
.kind(Kind::ApplicationSpecificData)
|
|
||||||
.identifier(state_identifier(id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
match client.database().query(filter).await?.into_iter().next() {
|
|
||||||
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The newest state document per community carried in the local database.
|
|
||||||
pub async fn load_states(client: &Client) -> Result<Vec<CommunityState>> {
|
|
||||||
let filter = Filter::new().kind(Kind::ApplicationSpecificData);
|
|
||||||
let mut newest: BTreeMap<CommunityId, Event> = BTreeMap::new();
|
|
||||||
|
|
||||||
for event in client.database().query(filter).await? {
|
|
||||||
let Some(id) = state_document_of(&event) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
match newest.get(&id) {
|
|
||||||
Some(existing) if existing.created_at >= event.created_at => {}
|
|
||||||
_ => {
|
|
||||||
newest.insert(id, event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut states = Vec::with_capacity(newest.len());
|
|
||||||
|
|
||||||
for event in newest.into_values() {
|
|
||||||
match serde_json::from_str::<CommunityState>(&event.content) {
|
|
||||||
Ok(state) => states.push(state),
|
|
||||||
Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(states)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_document_of(event: &Event) -> Option<CommunityId> {
|
|
||||||
let identifier = event.tags.identifier()?;
|
|
||||||
let hex = identifier.strip_prefix(STATE_PREFIX)?;
|
|
||||||
hex.parse().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn backfill(
|
|
||||||
client: &Client,
|
|
||||||
channel: &ChannelId,
|
|
||||||
held: &[(Epoch, [u8; 32])],
|
|
||||||
until: Option<Timestamp>,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<ChatRumor>> {
|
|
||||||
let planes = plane_keys(held, channel)?;
|
|
||||||
let authors: Vec<PublicKey> = planes.iter().map(|(_, group)| group.pk()).collect();
|
|
||||||
|
|
||||||
let mut cursor = until;
|
|
||||||
let mut seen: BTreeSet<EventId> = BTreeSet::new();
|
|
||||||
let mut found: Vec<ChatRumor> = Vec::new();
|
|
||||||
|
|
||||||
for _ in 0..MAX_PAGES {
|
|
||||||
let page = fetch_page(client, &authors, cursor, limit).await?;
|
|
||||||
|
|
||||||
if page.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen);
|
|
||||||
|
|
||||||
for (opened, rumor) in fresh {
|
|
||||||
if cache_rumor(client, channel, &opened).await? {
|
|
||||||
found.push(rumor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match next {
|
|
||||||
Some(next) => cursor = Some(next),
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
|
|
||||||
found.truncate(limit);
|
|
||||||
|
|
||||||
Ok(found)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn advance(
|
|
||||||
page: &BTreeSet<Event>,
|
|
||||||
planes: &[(Epoch, GroupKey)],
|
|
||||||
channel: &ChannelId,
|
|
||||||
cursor: Option<Timestamp>,
|
|
||||||
limit: usize,
|
|
||||||
seen: &mut BTreeSet<EventId>,
|
|
||||||
) -> (Vec<(OpenedStream, ChatRumor)>, Option<Timestamp>) {
|
|
||||||
let mut fresh = Vec::new();
|
|
||||||
|
|
||||||
for wrap in page {
|
|
||||||
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
|
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok((opened, rumor)) = cord03::open(wrap, group, channel, *epoch) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if seen.insert(rumor.id) {
|
|
||||||
fresh.push((opened, rumor));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if fresh.is_empty() || page.len() < limit {
|
|
||||||
return (fresh, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let oldest = page.iter().map(|event| event.created_at).min();
|
|
||||||
|
|
||||||
match oldest {
|
|
||||||
Some(oldest) if cursor != Some(oldest) => (fresh, Some(oldest)),
|
|
||||||
_ => (fresh, None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_page(
|
|
||||||
client: &Client,
|
|
||||||
authors: &[PublicKey],
|
|
||||||
until: Option<Timestamp>,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<BTreeSet<Event>> {
|
|
||||||
let mut filter = Filter::new()
|
|
||||||
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
|
|
||||||
.authors(authors.iter().copied())
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
if let Some(until) = until {
|
|
||||||
filter = filter.until(until);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(client.fetch_events(filter).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::cord03::{build_message, seal_rumor};
|
|
||||||
use crate::cord05::ChannelGrant;
|
|
||||||
use crate::derive::channel_group_key;
|
|
||||||
use crate::{Epoch, Extra};
|
|
||||||
|
|
||||||
const SECRET: [u8; 32] = [0x07u8; 32];
|
|
||||||
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
|
|
||||||
|
|
||||||
/// What a relay does with an inclusive `until` and a `limit`.
|
|
||||||
fn serve_page(
|
|
||||||
relay: &BTreeSet<Event>,
|
|
||||||
cursor: Option<Timestamp>,
|
|
||||||
limit: usize,
|
|
||||||
) -> BTreeSet<Event> {
|
|
||||||
let mut events: Vec<Event> = relay
|
|
||||||
.iter()
|
|
||||||
.filter(|event| cursor.is_none_or(|cursor| event.created_at <= cursor))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
events.sort_by_key(|event| Reverse(event.created_at));
|
|
||||||
events.truncate(limit);
|
|
||||||
events.into_iter().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn history_pages_back_across_a_rekey() {
|
|
||||||
let channel = ChannelId::from_bytes([0x9cu8; 32]);
|
|
||||||
let author = Keys::generate();
|
|
||||||
let held = [(Epoch(0), SECRET), (Epoch(1), NEXT_SECRET)];
|
|
||||||
let planes = plane_keys(&held, &channel).expect("derives");
|
|
||||||
|
|
||||||
// Three messages a second apart: a page boundary falls between each.
|
|
||||||
let base = 1_700_000_000_000;
|
|
||||||
let mut relay: BTreeSet<Event> = BTreeSet::new();
|
|
||||||
|
|
||||||
for (content, secret, epoch, at_ms) in [
|
|
||||||
("before the rekey", &SECRET, Epoch(0), base),
|
|
||||||
("still before", &SECRET, Epoch(0), base + 1_000),
|
|
||||||
("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000),
|
|
||||||
] {
|
|
||||||
let group = channel_group_key(secret, &channel, epoch).expect("derives");
|
|
||||||
let rumor = build_message(
|
|
||||||
author.public_key(),
|
|
||||||
&channel,
|
|
||||||
epoch,
|
|
||||||
content,
|
|
||||||
None,
|
|
||||||
at_ms,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
relay.insert(
|
|
||||||
smol::block_on(seal_rumor(&rumor, &group, &author, false))
|
|
||||||
.expect("seals")
|
|
||||||
.0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut seen = BTreeSet::new();
|
|
||||||
let mut found = Vec::new();
|
|
||||||
let mut cursor = None;
|
|
||||||
|
|
||||||
for _ in 0..3 {
|
|
||||||
let page = serve_page(&relay, cursor, 2);
|
|
||||||
let (fresh, next) = advance(&page, &planes, &channel, cursor, 2, &mut seen);
|
|
||||||
|
|
||||||
found.extend(fresh.into_iter().map(|(_, rumor)| rumor));
|
|
||||||
|
|
||||||
match next {
|
|
||||||
Some(next) => cursor = Some(next),
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
|
|
||||||
|
|
||||||
let contents: Vec<&str> = found.iter().map(|rumor| rumor.content.as_str()).collect();
|
|
||||||
assert_eq!(
|
|
||||||
contents,
|
|
||||||
["after the rekey", "still before", "before the rekey"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
|
|
||||||
let owner = Keys::generate().public_key();
|
|
||||||
let control_pk = Keys::generate().public_key();
|
|
||||||
let staff = ChannelId::from_bytes([0x9c; 32]);
|
|
||||||
let general = ChannelId::from_bytes([0x9d; 32]);
|
|
||||||
|
|
||||||
let material = JoinMaterial {
|
|
||||||
community_id: CommunityId::from_bytes([0x42; 32]),
|
|
||||||
owner,
|
|
||||||
owner_salt: "01".repeat(32),
|
|
||||||
community_root: "02".repeat(32),
|
|
||||||
root_epoch: Epoch(3),
|
|
||||||
control_pk: Some(control_pk),
|
|
||||||
control_root: Some("03".repeat(32)),
|
|
||||||
channels: vec![
|
|
||||||
ChannelGrant {
|
|
||||||
id: staff,
|
|
||||||
key: Some("04".repeat(32)),
|
|
||||||
epoch: Epoch(2),
|
|
||||||
name: "staff".to_owned(),
|
|
||||||
extra: Extra::default(),
|
|
||||||
},
|
|
||||||
ChannelGrant {
|
|
||||||
id: general,
|
|
||||||
key: None,
|
|
||||||
epoch: Epoch(0),
|
|
||||||
name: "general".to_owned(),
|
|
||||||
extra: Extra::default(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
relays: vec!["wss://relay.example".to_owned()],
|
|
||||||
name: "Room".to_owned(),
|
|
||||||
extra: Extra::default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let state = CommunityState::from_join_material(&material, 7).expect("materializes");
|
|
||||||
|
|
||||||
assert_eq!(state.id, material.community_id);
|
|
||||||
assert_eq!(state.owner, owner);
|
|
||||||
assert_eq!(state.owner_salt, [0x01; 32]);
|
|
||||||
assert_eq!(state.community_root, [0x02; 32]);
|
|
||||||
assert_eq!(state.root_epoch, Epoch(3));
|
|
||||||
assert_eq!(state.control_root, Some([0x03; 32]));
|
|
||||||
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
|
|
||||||
assert!(
|
|
||||||
state.heads.is_empty(),
|
|
||||||
"the first control fold fills the heads"
|
|
||||||
);
|
|
||||||
assert!(state.banned.is_empty());
|
|
||||||
assert!(!state.dissolved);
|
|
||||||
assert_eq!(state.relays.len(), 1);
|
|
||||||
assert_eq!(state.added_at_ms, 7);
|
|
||||||
|
|
||||||
// A granted key lands on the channel and makes it private; a grant with
|
|
||||||
// no key is a public channel.
|
|
||||||
let granted = state
|
|
||||||
.channels
|
|
||||||
.iter()
|
|
||||||
.find(|c| c.id == staff)
|
|
||||||
.expect("staff");
|
|
||||||
assert!(granted.private);
|
|
||||||
assert_eq!(granted.key, Some([0x04; 32]));
|
|
||||||
assert_eq!(granted.epoch, Epoch(2));
|
|
||||||
assert_eq!(granted.name, "staff");
|
|
||||||
|
|
||||||
let public = state
|
|
||||||
.channels
|
|
||||||
.iter()
|
|
||||||
.find(|c| c.id == general)
|
|
||||||
.expect("general");
|
|
||||||
assert!(!public.private);
|
|
||||||
assert_eq!(public.key, None);
|
|
||||||
|
|
||||||
// A member who is not staff carries no control_root, but reading needs no
|
|
||||||
// secret: the address rides in the material either way.
|
|
||||||
let mut member = material.clone();
|
|
||||||
member.control_root = None;
|
|
||||||
let state = CommunityState::from_join_material(&member, 7).expect("materializes");
|
|
||||||
assert_eq!(state.control_root, None);
|
|
||||||
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_states_reads_one_document_per_community_and_ignores_other_documents() {
|
|
||||||
smol::block_on(async {
|
|
||||||
let client = ClientBuilder::default()
|
|
||||||
.database(nostr_memory::MemoryDatabase::unbounded())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let state = CommunityState {
|
|
||||||
id: CommunityId::from_bytes([0x42; 32]),
|
|
||||||
name: Some("Anime and Manga".to_owned()),
|
|
||||||
owner: Keys::generate().public_key(),
|
|
||||||
owner_salt: [0x01; 32],
|
|
||||||
community_root: [0x02; 32],
|
|
||||||
root_epoch: Epoch(0),
|
|
||||||
control_root: None,
|
|
||||||
control_pks: BTreeMap::new(),
|
|
||||||
channels: Vec::new(),
|
|
||||||
relays: Vec::new(),
|
|
||||||
heads: Vec::new(),
|
|
||||||
banned: BTreeSet::new(),
|
|
||||||
dissolved: false,
|
|
||||||
added_at_ms: 7,
|
|
||||||
};
|
|
||||||
|
|
||||||
save_state(&client, &state).await.expect("saves");
|
|
||||||
|
|
||||||
// A cached rumor is also an application-specific document, but not a
|
|
||||||
// state document, so the prefix keeps it out of the state scan.
|
|
||||||
let other = EventBuilder::new(Kind::ApplicationSpecificData, "{}")
|
|
||||||
.tags([Tag::identifier("deadbeef")])
|
|
||||||
.finalize(&*LOCAL_KEYS)
|
|
||||||
.expect("builds");
|
|
||||||
client.database().save_event(&other).await.expect("saves");
|
|
||||||
|
|
||||||
let loaded = load_states(&client).await.expect("loads");
|
|
||||||
|
|
||||||
assert_eq!(loaded, vec![state]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
# Community history and live sync
|
||||||
|
|
||||||
|
A community panel currently shows a short, stale slice of a channel: often only
|
||||||
|
the newest handful of wraps a relay happened to replay, no older history, no
|
||||||
|
messages for private channels, and nothing at all after a rekey. The reference
|
||||||
|
client (`soapbox-pub/armada`, `src/concord/lib/channelSync.ts` and friends) pages
|
||||||
|
history from relays into a local store, keeps a cursor per channel, and keeps the
|
||||||
|
timeline live. This document is the diagnosis and the plan to get there.
|
||||||
|
|
||||||
|
**Revision 2.** Three corrections to the first revision, all of them structural:
|
||||||
|
|
||||||
|
1. **`concord` stays a thin protocol layer.** Relay and database operations do
|
||||||
|
not belong in it. Revision 1 put the whole paged fetch — `fetch_page`,
|
||||||
|
`Window`, `WrapPage`, `Walk`, the per-page subscriptions — into
|
||||||
|
`concord/src/store.rs`, next to a local cache layer that writes to the client
|
||||||
|
database. That is now framed as the mistake it is: `concord` keeps wire
|
||||||
|
formats, crypto, and the plain community document; `community` keeps every
|
||||||
|
`Client`.
|
||||||
|
2. **One notification pump, subscriptions that stay.** `community` already has a
|
||||||
|
global relay notification handler (`CommunityRegistry::handle_notifications`).
|
||||||
|
The history path must go through it: subscribe to a filter, read the page back
|
||||||
|
from the local database, keep the live subscription alive for new data, and
|
||||||
|
let the pump — not a hand-rolled per-relay wait loop inside the protocol crate
|
||||||
|
— be the only consumer of relay notifications.
|
||||||
|
3. **Cold asks for everything, warm asks for what is new.** Opening a community
|
||||||
|
with nothing cached subscribes wide (`since = None`) and pages down; opening
|
||||||
|
it again asks only for the region since the cursor. Exhaustion still has to be
|
||||||
|
earned (§3).
|
||||||
|
|
||||||
|
And one correction to a claim in revision 1: an `auth-required` CLOSE is **not** a
|
||||||
|
failure. The SDK marks that subscription for resubscription and re-issues the REQ
|
||||||
|
under the same id once the AUTH handshake completes — for auto-closing
|
||||||
|
subscriptions too, which is what a page REQ is (`nostr-sdk/src/relay/inner.rs`
|
||||||
|
`handle_relay_message` → `MarkAsClosed`, and the auto-closing handler's
|
||||||
|
`RelayNotification::Authenticated` arm). Only `AuthenticationFailed` ends a relay's
|
||||||
|
wait, and it is reported rather than rendered as an empty channel.
|
||||||
|
|
||||||
|
Read path today (phase 1 landed the walk in `concord/src/store.rs`; phase 2a has
|
||||||
|
since moved each layer to where it belongs — the paths here are the current ones):
|
||||||
|
|
||||||
|
```
|
||||||
|
CommunityPanel::load community_ui/src/lib.rs:192
|
||||||
|
-> Community::sync_channel community/src/community.rs:218
|
||||||
|
-> sync_round community/src/community.rs:604
|
||||||
|
-> history::page community/src/history.rs (per-relay
|
||||||
|
-> ingest_page community/src/history.rs subscribe +
|
||||||
|
notification wait)
|
||||||
|
-> client.database().query(filter) (the page)
|
||||||
|
-> cache::cache_rumor community/src/cache.rs
|
||||||
|
-> Community::timeline community/src/community.rs:368
|
||||||
|
-> cache::query_rumors community/src/cache.rs
|
||||||
|
-> cord03::fold concord/src/cords/cord03.rs
|
||||||
|
|
||||||
|
live wire community/src/lib.rs:307 (sync_subscriptions)
|
||||||
|
-> sync::subscription_filter community/src/sync.rs:79 (kind 1059 only,
|
||||||
|
no since, no limit)
|
||||||
|
-> pump community/src/lib.rs:352 (drops every
|
||||||
|
Message and every
|
||||||
|
non-1059 event)
|
||||||
|
```
|
||||||
|
|
||||||
|
## What is wrong today
|
||||||
|
|
||||||
|
| # | Finding | Status | Evidence |
|
||||||
|
| - | ------- | ------ | -------- |
|
||||||
|
| 1 | History was fetched at most once per channel, only when the cache was completely empty, so the relay round was almost always skipped. | fixed in phase 1 (`sync_channel` runs per open) | `community/src/community.rs:218` |
|
||||||
|
| 2 | That one round was one shallow page with no continuation. | fixed in phase 1 (paged walk + cursors) | `community/src/history.rs` |
|
||||||
|
| 3 | The timeline was a fixed 200-row window with no way to ask for older. | fixed in phase 1 (`timeline` + `has_more` + load-older) | `community_ui/src/lib.rs:263` |
|
||||||
|
| 4 | Cursors existed but nothing used them; no "has more" signal. | fixed in phase 1 | `community/src/community.rs:337` |
|
||||||
|
| 5 | **Private channels are never subscribed and never folded**: `planes()` skips `channel.private`, so a private channel gets no live REQ and no `cache_rumor` from the subscription. | open (phase 2b) | `community/src/sync.rs:63-66` |
|
||||||
|
| 6 | The standing REQ asks for **kind 1059 only**, and the pump drops anything that is not 1059, so 21059 (ephemeral) wraps can never be routed even though the read path asks for both kinds. | open (phase 2b) | `community/src/sync.rs:79-83`, `community/src/lib.rs:380-383` |
|
||||||
|
| 7 | A **rekey strands history**: `ChannelKeyRef` holds one epoch/key, `channel_secret` returns one plane, `sync::refresh` overwrites a held key in place, and the rekey pseudonyms are never watched. | open (phase 3) | `community/src/sync.rs:331-368`, `community/src/community.rs:199-216` |
|
||||||
|
| 8 | **"No messages yet" is three different states**: unreadable wraps are dropped silently, a failed round is logged, and the panel renders all of them as an empty room. | open (phase 4; counts already exist in `Progress`) | `community/src/community.rs:38-44`, `community_ui/src/lib.rs:594-602` |
|
||||||
|
| 9 | A new message **replaced the whole timeline and forced `scroll_to_end()`**. | fixed in phase 1 (`FollowMode::Tail`, in-place merge) | `community_ui/src/lib.rs:121`, `:364-411` |
|
||||||
|
| 10 | Backfill fetched through `client.fetch_events(..)` with `ReqTarget::auto`, i.e. every relay in the pool. | fixed in phase 1 (relay-scoped, no `fetch_events` anywhere) | `community/src/community.rs:262` |
|
||||||
|
| 11 | The local cache document is authored by a **process-random key**, so the same rumor cached in two runs is a different event id and the store keeps both copies; `fold` then re-reads all of it on every inbound wrap. | fixed in phase 2a (one fixed cache key) | `community/src/cache.rs` |
|
||||||
|
| 12 | `cache::purge_expired` is never called, so expired rows only drop at fold time, never from disk. | open (phase 2b) | `community/src/cache.rs` (no call sites) |
|
||||||
|
| 13 | **Every relay and database operation lived in `concord`**: `fetch_page` installed its own subscriptions and read the client database, `cache_rumor`/`query_rumors`/`save_state`/`load_states` wrote and read it. Consequences: the pump could not see a page's REQ (so the panel's live view and the history path were two unrelated worlds), each page hand-rolled a per-relay notification loop, and the protocol crate could not be built or tested without a `Client`. | fixed in phase 2a (split into `concord/src/state.rs`, `community/src/cache.rs` and `community/src/history.rs`; the page REQ still waits on its own loop — §2 moves it into the pump) | `concord/src/state.rs`, `community/src/{cache,history}.rs` |
|
||||||
|
| 14 | **The fold is O(history)**: `sync::fold` re-reads every wrap in the community's planes and NIP-44-opens each one on every inbound wrap, purely to cache channel rumors and observe their authors for the member list. The live REQ replays the plane on every start, so this happens on every app run and every burst of messages. | open (phase 2b) | `community/src/sync.rs:423-517` |
|
||||||
|
|
||||||
|
Findings 1-4 and 9-10 are the user-visible symptom and were phase 1; 13 is where
|
||||||
|
that work landed in the wrong crate; 5-7 are why some channels look empty forever;
|
||||||
|
8 is why a failure looks like an empty room; 11 and 14 are why the store and the
|
||||||
|
fold get slower the fuller a community is.
|
||||||
|
|
||||||
|
## What the reference client does
|
||||||
|
|
||||||
|
Observed in Armada (`src/concord/lib/channelSync.ts`, `src/concord/hooks/useChannel.ts`,
|
||||||
|
`src/wire/spec.ts`, `src/concord/hooks/useRekey.ts`):
|
||||||
|
|
||||||
|
| Mechanism | Reference detail |
|
||||||
|
| --------- | ---------------- |
|
||||||
|
| Local-first read | the query resolves on an IndexedDB read (`WINDOW_SIZE = 100` rows) and network catch-up runs behind it, so the panel never waits on relays |
|
||||||
|
| Three-pass round | **newest page** → **bridge** (`since: cursor.newest`, `until: newest.oldest - 1`, healing an offline burst larger than one page) → **older** (`until: cursor.oldest`, resumed on later rounds) |
|
||||||
|
| Page size | `BACKFILL_PAGE = 50` wraps per relay per page, `BACKFILL_MAX_PAGES = 20` per round, `LOAD_OLDER_MAX_PAGES = 6` per scroll-up |
|
||||||
|
| Cursors | `{ newest, oldest, exhausted }` per channel, persisted locally, merged monotonically (`newest` forward only, `oldest` back only, `exhausted` sticky), advanced **only** when the region is verifiably complete |
|
||||||
|
| Failure semantics | a failed relay blocks exhaustion and cursor advancement; an all-empty round is a failure, never exhaustion (otherwise a notification-only room is sealed as "exhausted" and never pulls its history) |
|
||||||
|
| Page-by-page decode | each page is decrypted and committed as it lands; a deep round paints as it goes |
|
||||||
|
| Scroll-up paging | local store first (pure re-read), relays only when the store is exhausted |
|
||||||
|
| Side events | messages and their decorations (edits/deletes/reactions) get **separate budgets** (×4) so reaction floods cannot displace rows and edits outside the row window still fold |
|
||||||
|
| Live wire | one REQ per relay with the channel's **current** stream addresses; every held epoch stays in the decode set; retired epochs are asked once more, then frozen out of the REQ |
|
||||||
|
| Rekeys | a watch on `baseRekeyGroupKey(root, id, rootEpoch + 1)` plus `CHANNEL_REKEY_LOOKAHEAD = 8` epochs of channel rekey pseudonyms; adoption clears `exhausted` and re-runs the channel's sync round |
|
||||||
|
| Polling | a scheduler re-runs a channel round when it is older than 5 minutes, at most once per 30 s |
|
||||||
|
|
||||||
|
## The flow we are converging on: subscribe, then read the database
|
||||||
|
|
||||||
|
The `Signed` app (`crates/signed_state/src`) is the model, and it maps onto what
|
||||||
|
`community` already has:
|
||||||
|
|
||||||
|
| `Signed` | coop |
|
||||||
|
| -------- | ---- |
|
||||||
|
| `Backend` owns the `Client` and runs **one** notification pump that batches relay events into `BackendEvent::NostrUpdate(Vec<Update>)` within `PUMP_DEBOUNCE = 200ms` | `state::NostrRegistry` owns the `Client`; `CommunityRegistry` runs the pump |
|
||||||
|
| `Backend::subscribe_bootstrap(filters)` / `sync_bootstrap(filter)` install the REQ; the pump delivers what relays send into the database | `CommunityRegistry::sync_subscriptions` installs the live REQ; a round installs a page REQ |
|
||||||
|
| Stores (`RepoListStore`, `ProfileStore`) subscribe to backend events and re-query **only** `client.database()`, never the network | `Community::refresh` → `community::cache` reads |
|
||||||
|
| `RefreshGate` (`running`/`dirty`, one coalesced follow-up) | `Community::{refresh_task, dirty}` and `Round{running, queued, waiters}` |
|
||||||
|
| A store never awaits a relay: it reacts to a batched update and reads the database | a round awaits its own page's EOSE (through the pump) and then reads the database |
|
||||||
|
|
||||||
|
Two consequences for this codebase, stated as rules:
|
||||||
|
|
||||||
|
- **A relay is only ever asked by a subscription.** No `fetch_events`, no
|
||||||
|
`ReqTarget::auto`, no per-relay `notifications()` loop outside the pump.
|
||||||
|
- **Every read is a database read.** Cursors, folds, timelines, member lists and
|
||||||
|
pages are computed from what the database holds after a subscription put it
|
||||||
|
there.
|
||||||
|
|
||||||
|
## The fix
|
||||||
|
|
||||||
|
### 1. `concord` keeps the protocol, `community` keeps the relays and the database
|
||||||
|
|
||||||
|
`concord` gets one rule: **no `Client`, no database, no subscription**. Its
|
||||||
|
remaining job is wire formats, crypto and the community document. Everything
|
||||||
|
that talks to a relay or the local store moves one crate up:
|
||||||
|
|
||||||
|
| Today | Target |
|
||||||
|
| ----- | ------ |
|
||||||
|
| `concord/src/store.rs`: `CommunityState`, `ChannelKeyRef`, `ChannelCursor` (+ `merge`), `from_genesis`, `from_join_material`, `apply_fold`, `floors`, `identifier`, `list_entry` | `concord/src/state.rs` — the document and its pure folds, no I/O |
|
||||||
|
| `cache_rumor`, `purge_expired`, `query_rumors`, `save_state`, `load_state`, `load_states`, `state_identifier`, `LOCAL_KEYS`, the `c`/`t`/`k`/`e` tags, `STATE_PREFIX` | `community/src/cache.rs` — the local cache and the local documents |
|
||||||
|
| `Window`, `WrapPage`, `fetch_page` (renamed `history::page`), `wrap_filter`, `ingest_page`, `auth_required`, `history_subscription`, `within`, `Walk`, `Walker` | `community/src/history.rs` — the page REQ and the walk |
|
||||||
|
| `planes`, `subscription_filter`, `subscription_id`, `community_of`, `subscribe_list`, `load`, `fold` | stay `community/src/sync.rs`, extended |
|
||||||
|
| `Signal`, `handle_notifications`, `sync_subscriptions`, `track`, `reset` | stay `community/src/lib.rs`, extended into the pump (§2) |
|
||||||
|
| The `Walk`/`serve_page` tests | move to `community/src/history.rs`; the `ChannelCursor::merge` and `from_join_material` tests stay with the document; the `load_states` test moves to `community` (it needs a database) |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The document module is `concord::state`; items are imported by full path
|
||||||
|
(`use concord::state::{ChannelCursor, CommunityState};`) so the separate
|
||||||
|
`state` crate keeps `state::…` for itself.
|
||||||
|
- `concord`'s dependency list shrinks: `futures` and `smol` are used only by the
|
||||||
|
relay code that moves out (`smol` stays, as a dev-dependency, for the cord
|
||||||
|
tests).
|
||||||
|
- `ChannelCursor` stays a plain data type inside `CommunityState` (it is
|
||||||
|
persisted, and its `merge` is pure). What moves out is *when* to advance it —
|
||||||
|
that is `community`'s judgement, not the protocol's.
|
||||||
|
- Two deviations from the table, both to keep the layers honest.
|
||||||
|
`STATE_PREFIX`, `state_identifier` and `CommunityState::identifier` stay in
|
||||||
|
`concord::state`: they name the document's own local key, and the document
|
||||||
|
cannot reach up into `community` to borrow them. And `community`'s `cache` and
|
||||||
|
`history` are `pub mod`, so the surface that used to be `concord::store` is
|
||||||
|
still reachable (a private module turns `purge_expired` and `load_state` into
|
||||||
|
dead code).
|
||||||
|
- `docs/concord-usage.md` documented `store::fetch_page`; it was updated in the
|
||||||
|
same change.
|
||||||
|
|
||||||
|
### 2. One notification pump, and subscriptions that stay
|
||||||
|
|
||||||
|
#### Subscription ids and their routes
|
||||||
|
|
||||||
|
| Subscription | Id | Owner | Lifetime |
|
||||||
|
| ------------ | -- | ----- | -------- |
|
||||||
|
| Community List | `concord-list/<self pk>` (existing) | registry | signer lifetime |
|
||||||
|
| Live planes | `<community hex>` (existing `sync::subscription_id`) | community | until the signer or the plane set changes; **kept alive** |
|
||||||
|
| History page | `concord-history/<community hex>/<channel hex>/<n>` | the round | one page; auto-closes on EOSE |
|
||||||
|
| Rekey watch (phase 3) | `concord-rekey/<community hex>/<n>` | community | kept alive while the community is tracked |
|
||||||
|
|
||||||
|
A single `route_of(&SubscriptionId) -> Option<Route>` parses the id back into
|
||||||
|
`Route::{List, Community(CommunityId), History { community, channel }, Rekey(community)}`,
|
||||||
|
so the pump routes by subscription id first and never has to guess from an event.
|
||||||
|
|
||||||
|
#### The pump
|
||||||
|
|
||||||
|
`CommunityRegistry::handle_notifications` becomes the only consumer of
|
||||||
|
`client.notifications()`, and it handles the message variants it drops today:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
loop {
|
||||||
|
match notifications.next().await {
|
||||||
|
Some(ClientNotification::Event { subscription_id, event, .. }) => {
|
||||||
|
match route_of(&subscription_id) {
|
||||||
|
Some(Route::List) => batch.list = true,
|
||||||
|
// Only the database is told; the fold reads it when the window closes.
|
||||||
|
Some(Route::Community(id)) => batch.events.insert(id),
|
||||||
|
// A page and a rekey watch are read from the database later:
|
||||||
|
// the page when its relays settle, the rekey when the batch closes.
|
||||||
|
Some(Route::History { .. }) => {}
|
||||||
|
Some(Route::Rekey(id)) => batch.rekeys.insert(id),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ClientNotification::Message { relay_url, message }) => match *message {
|
||||||
|
RelayMessage::EndOfStoredEvents(id) => pages.settled(&id, relay_url, Settled::Replayed),
|
||||||
|
RelayMessage::Closed { subscription_id, message }
|
||||||
|
if !auth_required(&message) =>
|
||||||
|
{
|
||||||
|
pages.settled(&subscription_id, relay_url, Settled::Refused(message));
|
||||||
|
}
|
||||||
|
// auth-required: the SDK re-issues this REQ under the same id after
|
||||||
|
// AUTH, so the page keeps waiting for the resubscribed answer.
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Some(ClientNotification::Shutdown) | None => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Kind 1059 and 21059 both route** (finding 6); the previous revision's
|
||||||
|
"drops every non-1059" check goes away entirely.
|
||||||
|
- The batch is closed after `PUMP_WINDOW` (200 ms, the reference app's value) of
|
||||||
|
quiet, and what it produces is one `Signal::Event(id)` per community that saw
|
||||||
|
an event plus one `Signal::List` — so a burst of fifty messages costs **one**
|
||||||
|
fold, not fifty. `Community::refresh` keeps its own `dirty` follow-up.
|
||||||
|
- `pages` is the registry's page registry:
|
||||||
|
`HashMap<(CommunityId, ChannelId), flume::Sender<PageReport>>`, registered by a
|
||||||
|
round before it subscribes and keyed off the id `route_of` parses back.
|
||||||
|
`PageReport { id, relay, outcome }` carries facts; the walk decides what they
|
||||||
|
mean. Registration happens once per round (not per page), and the round matches
|
||||||
|
reports by subscription id.
|
||||||
|
|
||||||
|
#### The live REQ stays
|
||||||
|
|
||||||
|
`CommunityRegistry::sync_subscriptions` keeps installing one REQ per community
|
||||||
|
over `state.relays` (`ReqTarget::manual`, never `auto`), and it keeps that
|
||||||
|
subscription alive: a long-lived subscription is re-sent by the SDK after a
|
||||||
|
reconnect and after a successful AUTH, which is exactly the "resync on socket
|
||||||
|
reopen" behaviour the reference client implements by hand.
|
||||||
|
|
||||||
|
What changes is the filter: it now asks for both wrap kinds, includes private
|
||||||
|
channels (§5), and carries a **window**:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// What a community with no history in the database asks for: everything a relay
|
||||||
|
/// stores, bounded per relay so a cold start is not a whole plane.
|
||||||
|
const LIVE_REPLAY: usize = 500;
|
||||||
|
|
||||||
|
impl Window {
|
||||||
|
/// The window a channel is opened with.
|
||||||
|
///
|
||||||
|
/// Nothing held: ask wide, the round pages down from there.
|
||||||
|
/// Something held: ask only for what is new, plus an overlap for the seam.
|
||||||
|
pub fn opening(cursor: ChannelCursor) -> Self {
|
||||||
|
match cursor.newest_ms {
|
||||||
|
Some(newest_ms) => Window {
|
||||||
|
since_ms: Some(newest_ms.saturating_sub(CURSOR_OVERLAP_MS)),
|
||||||
|
until_ms: None,
|
||||||
|
},
|
||||||
|
None => Window::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The floor is computed **when the REQ is installed** (on open, on a plane change,
|
||||||
|
on a signer change) and is not recomputed as cursors advance: a live REQ that is
|
||||||
|
re-subscribed on every cursor merge would replay the seam on every message, and
|
||||||
|
the region it did not cover is the round's job, not the live REQ's.
|
||||||
|
|
||||||
|
### 3. A channel round over page subscriptions
|
||||||
|
|
||||||
|
`Community::sync_channel(&self, channel, intent, cx)` keeps its shape and its
|
||||||
|
coalescing (`Round { running, queued, waiters }`, one round per channel, a
|
||||||
|
request that arrives mid-round re-runs it once), and the panel keeps its two
|
||||||
|
intents:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum Intent {
|
||||||
|
/// Opening a channel: newest data, the bridge, then a bounded older walk.
|
||||||
|
CatchUp,
|
||||||
|
/// Scrolling up: continue older history, at most `pages` pages.
|
||||||
|
Older { pages: usize },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
What changes is the transport. A page is now:
|
||||||
|
|
||||||
|
1. the round registers its `PageReport` sender for `(community, channel)`;
|
||||||
|
2. it installs **one REQ per page** over `state.relays`
|
||||||
|
(`client.subscribe(manual(relays, filter)).with_id(page_id).close_on(ExitOnEOSE + PAGE_TIMEOUT)`),
|
||||||
|
tagged with a unique `history_subscription_id`;
|
||||||
|
3. it awaits the pump's reports for that id until every relay it asked has
|
||||||
|
settled (EOSE, or a non-auth CLOSED) or `PAGE_TIMEOUT` passes;
|
||||||
|
4. it reads the page **from the database** with the same filter the REQ used.
|
||||||
|
|
||||||
|
Rules carried over from phase 1, none of which are negotiable:
|
||||||
|
|
||||||
|
- **A page boundary is exclusive.** `until = oldest_seen_ms - 1`, so consecutive
|
||||||
|
pages never share an event and the walk terminates.
|
||||||
|
- **`exhausted` is earned.** Only `raw > 0` plus a bottomed-out page on every
|
||||||
|
relay that answered may set it. An all-empty page sets `failed`, so the next
|
||||||
|
round re-asks the same region instead of sealing the channel at "no more
|
||||||
|
history".
|
||||||
|
- **A relay that never answered** (deadline) or **refused** (any CLOSED that is
|
||||||
|
not `auth-required`) is out of the walk and blocks `exhausted`; it is counted
|
||||||
|
in `Progress.errors`.
|
||||||
|
- **`auth-required` is not a failure.** The relay stays in the walk and the page
|
||||||
|
waits for the re-issued REQ's EOSE. Only `AuthenticationFailed` settles a relay
|
||||||
|
as refused, and it is surfaced.
|
||||||
|
- **One cursor, one filter per page.** The database is not per relay, so per-relay
|
||||||
|
cursors do not exist; a relay is a source that fills the database.
|
||||||
|
- **`newest_ms` advances only on a complete round** (`!newest.failed && bridge.exhausted`),
|
||||||
|
`oldest_ms` only walks down on a complete page, and `exhausted` is cleared by a
|
||||||
|
rekey.
|
||||||
|
|
||||||
|
The cold/warm distinction is now the *window rule* rather than a special case:
|
||||||
|
|
||||||
|
| State of the channel | Window |
|
||||||
|
| -------------------- | ------ |
|
||||||
|
| no cursor, nothing cached (cold open) | `Window::opening` → `since = None`: subscribe for everything the relays have, then page down |
|
||||||
|
| cursor with `newest_ms` (warm open) | `Window::opening` → `since = newest - CURSOR_OVERLAP`: new data only |
|
||||||
|
| cursor with `oldest_ms` | `Window::older_than(oldest_ms)`: older data, on demand |
|
||||||
|
| a hole between `saved.newest_ms` and the newest seen | `Window::between(..)`: the bridge |
|
||||||
|
|
||||||
|
So an open does two things, and they follow the same rule: it makes sure the live
|
||||||
|
REQ is installed — wide when nothing is held, which is where "subscribe to get all
|
||||||
|
data" comes from — and it runs a round whose newest pass asks only for the region
|
||||||
|
the live REQ did not already cover (a warm open therefore replays nothing: the
|
||||||
|
REQ is already streaming).
|
||||||
|
|
||||||
|
And the passes fall out of that rule:
|
||||||
|
|
||||||
|
- **Newest pass** (`CatchUp`): one page at `Window::opening(saved)`. Cold, this is
|
||||||
|
"get all data"; warm, this is "get only new data".
|
||||||
|
- **Bridge**: while the previous page was *full* and still above
|
||||||
|
`saved.newest_ms`, keep walking down with `until = oldest - 1`, bounded by
|
||||||
|
`CATCH_UP_PAGES`. A short page ends it, which is what lets `newest_ms` advance.
|
||||||
|
- **Older pass**: resume at `saved.oldest_ms.or(oldest_seen)` and walk down,
|
||||||
|
bounded by `CATCH_UP_PAGES` on a catch-up and `LOAD_OLDER_PAGES` on a scroll-up.
|
||||||
|
|
||||||
|
Sizes stay the reference numbers: `PAGE_WRAPS = 50`, `CATCH_UP_PAGES = 20`,
|
||||||
|
`LOAD_OLDER_PAGES = 6`, `CURSOR_OVERLAP = 60s`.
|
||||||
|
|
||||||
|
Known limitation, unchanged and deliberately deferred: the bridge is bounded by
|
||||||
|
`CATCH_UP_PAGES`, so an offline burst larger than `CATCH_UP_PAGES * PAGE_WRAPS`
|
||||||
|
(1,000 wraps) is not repaired by one round and does not heal later (the bridge
|
||||||
|
restarts from the same point). Fixing it needs a fourth cursor field recording how
|
||||||
|
far a partial repair walked. One cheap improvement belongs in phase 2b: skip the
|
||||||
|
older pass entirely when `saved.exhausted` is already set.
|
||||||
|
|
||||||
|
### 4. The read path
|
||||||
|
|
||||||
|
Unchanged from phase 1, moved intact:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Timeline {
|
||||||
|
/// Oldest first, ready for a bottom-aligned list.
|
||||||
|
pub messages: Vec<ChatMessage>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn timeline(&self, channel: &ChannelId, before_ms: Option<u64>, limit: usize, cx: &App)
|
||||||
|
-> Task<Result<Timeline>>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Reads `limit + 1` rows to compute `has_more`, plus the side-event budget over
|
||||||
|
the same span (`SIDE_EVENT_FACTOR = 4`), folds with `cord03::fold` (unchanged —
|
||||||
|
it already resolves edits, deletes and reactions), reverses, returns.
|
||||||
|
- `TIMELINE_PAGE = 100` is the first paint; older history stays on disk and comes
|
||||||
|
back when the user scrolls up.
|
||||||
|
- The panel's only read is `timeline`.
|
||||||
|
|
||||||
|
### 5. Live completeness
|
||||||
|
|
||||||
|
- **Private channels get a plane.** `sync::planes` derives it from the held key
|
||||||
|
(`channel_secret` already knows the rule); a private channel is subscribed and
|
||||||
|
folded exactly like a public one (finding 5).
|
||||||
|
- **Both wrap kinds.** The filter asks for `KIND_WRAP` and `KIND_WRAP_EPHEMERAL`
|
||||||
|
in the live REQ and in every page REQ, and the pump routes both (finding 6).
|
||||||
|
- **Community relays only.** The live REQ and every page REQ use
|
||||||
|
`ReqTarget::manual` over `state.relays`; a relay outside the community is never
|
||||||
|
asked (finding 10).
|
||||||
|
- **Expired rows are swept** on the open and round cadence with
|
||||||
|
`cache::purge_expired` (finding 12).
|
||||||
|
- **A stable cache key.** One local signing key persisted in the config dir
|
||||||
|
replaces `LazyLock::new(Keys::generate)` (finding 11); the same rumor cached
|
||||||
|
twice then has one addressable coordinate, so `save_event` replaces its
|
||||||
|
predecessor instead of adding a copy per app run.
|
||||||
|
|
||||||
|
### 6. Held epochs and rekey adoption (phase 3)
|
||||||
|
|
||||||
|
The client has to be able to read the epochs it is supposed to read, and learn
|
||||||
|
the next ones:
|
||||||
|
|
||||||
|
- `ChannelKeyRef` gains
|
||||||
|
`#[serde(default, skip_serializing_if = "Vec::is_empty")] priors: Vec<HeldKey>`
|
||||||
|
where `HeldKey { epoch: Epoch, key: [u8; 32], retired_at: Option<u64> }`, and
|
||||||
|
`CommunityState` gains the same shape for roots (`HeldRoot { epoch, key,
|
||||||
|
`control_pk, retired_at }`). `channel_secret` returns every held epoch,
|
||||||
|
`history::page` already takes `&[(Epoch, [u8; 32])]`, and `sync::refresh` stops
|
||||||
|
overwriting a held key in place and pushes the old one onto `priors`.
|
||||||
|
- **`retired_at` is a read cutoff.** The reference retires the key a rotation
|
||||||
|
steps off **at the rotation's own publish time**: nothing sealed under that key
|
||||||
|
after it is read again. The read side needs the same floor: a wrap at epoch *e*
|
||||||
|
whose `created_at` is past `priors[e].retired_at` is refused, at both the page
|
||||||
|
and the fold.
|
||||||
|
- **The rekey watch is a subscription too**: one long-lived REQ per community over
|
||||||
|
its relays for `authors(rekey pseudonyms)`, `kinds([KIND_WRAP])`, where the
|
||||||
|
authors are `cord06::rekey_group(RekeyScope::Base, held_root, id, epoch + 1)`
|
||||||
|
and — for every private channel and **every held root** (a Refounding seals
|
||||||
|
channel rekeys under the prior root) —
|
||||||
|
`cord06::rekey_group(RekeyScope::Channel(channel), held_root, channel, epoch)`
|
||||||
|
for `epoch in held_epoch + 1 ..= held_epoch + REKEY_LOOKAHEAD` (`8`).
|
||||||
|
- Adopt strictly, one epoch at a time, off the key actually held:
|
||||||
|
`cord01::open_wrap` → `cord06::parse_rekey_chunk` → `cord06::collect_rotations`
|
||||||
|
→ `is_complete` → `rotation.continuity(held_epoch, held_key)` must be `Extends`
|
||||||
|
(`Gap` is fetched, never waived; `Fork` resolves with `cord06::fork_winner`'s
|
||||||
|
lowest-key rule) → `cord06::find_my_blobs` + `open_blob` → `KeyDelivery`.
|
||||||
|
- Persist per delivery, then **re-install the subscription** (the plane set
|
||||||
|
changed), clear that channel's `exhausted`, and run a `CatchUp` round for it.
|
||||||
|
- Two terminal states, both persisted and both shown, never rendered as an empty
|
||||||
|
timeline: **removed** (`cord06::am_i_removed` on a complete rotation at/after
|
||||||
|
my join whose rotator outranks me — `added_at_ms` and `rekey_authorized`
|
||||||
|
already carry the comparison) and **stranded** (a complete rotation ahead of my
|
||||||
|
epoch that predates my join and carries no blob for me, which says the invite
|
||||||
|
link is out of date).
|
||||||
|
|
||||||
|
### 7. Honest states (phase 4)
|
||||||
|
|
||||||
|
- `Progress` already carries `fetched`, `opened`, `exhausted`, `failed`, `errors`;
|
||||||
|
the fold reports what it could not open; `Snapshot` carries those counts.
|
||||||
|
- `CommunityEvent` gains "history exists that we cannot read" and "the last round
|
||||||
|
failed", and the panel renders "N messages here can't be read yet — the channel's
|
||||||
|
key for epoch 3 is missing" or "Couldn't reach the community's relays" with a
|
||||||
|
retry, instead of "No messages yet" (finding 8).
|
||||||
|
|
||||||
|
### 8. `community_ui`: reach the older rows
|
||||||
|
|
||||||
|
Landed in phase 1, unchanged by the re-layering: per-channel `rows`, `has_more`,
|
||||||
|
`loading`, `FollowMode::Tail` instead of `scroll_to_end()`, load-older from the
|
||||||
|
scroll handler plus an always-index-0 "Load earlier messages" row, prepend via
|
||||||
|
`splice(1..1, n)`, append via `splice(at..at, n)`, and in-place merging unless the
|
||||||
|
newest window no longer reaches the rows on screen.
|
||||||
|
|
||||||
|
Two rules survive, both about GPUI rather than about history:
|
||||||
|
|
||||||
|
- **Never `set_follow_mode` or force a scroll position inside a scroll-handler
|
||||||
|
callback** — the list holds its state borrowed while it invokes the handler, so
|
||||||
|
touching `ListState` there panics. `load_older` is written to avoid it.
|
||||||
|
- The panel's `Intent::{CatchUp, Older}` calls do not change: the round they
|
||||||
|
trigger is now subscription-driven, which is invisible to the list.
|
||||||
|
|
||||||
|
Still deferred: per-channel timeline state (switching back re-reads), and the
|
||||||
|
`MAX_TIMELINE_ROWS` trim (trimming the oldest rows fights `load_older`, which
|
||||||
|
prepends at the same end — the reader would oscillate).
|
||||||
|
|
||||||
|
### 9. The fold stops being O(history) (phase 2b)
|
||||||
|
|
||||||
|
`sync::fold` currently opens every wrap in the community's planes on every inbound
|
||||||
|
wrap: channel wraps are re-opened only to `cache_rumor` them again and to observe
|
||||||
|
their authors, which the database already holds as decoded rows (the cached row
|
||||||
|
carries the author in a `p` tag and the message time in `created_at`). Finding 14.
|
||||||
|
|
||||||
|
The shape of the fix, in the same phase because it is the same code path:
|
||||||
|
|
||||||
|
- Channel wraps are opened **once, on the way in** — the round does it for pages,
|
||||||
|
the pump does it for live wraps through a per-channel work queue — instead of
|
||||||
|
every fold.
|
||||||
|
- The fold keeps reading the control and guestbook planes from wraps (they are
|
||||||
|
small, and `cord02::fold_control` is a fold over all editions by construction).
|
||||||
|
- Member observation comes from the cached rows or from an incrementally
|
||||||
|
maintained per-channel map, not from re-opening history.
|
||||||
|
|
||||||
|
## Order of work
|
||||||
|
|
||||||
|
### Phase 2a — move the code (no behaviour change) — **landed**
|
||||||
|
|
||||||
|
1. `concord/src/store.rs` split into `concord/src/state.rs`, `community/src/cache.rs`
|
||||||
|
and `community/src/history.rs` per the table in §1; call sites updated
|
||||||
|
(`community/src/sync.rs`, `community/src/community.rs`, `community/src/lib.rs`,
|
||||||
|
and the `cord02` test's `crate::store::CommunityState`). `fetch_page` is now
|
||||||
|
`history::page`.
|
||||||
|
2. The local cache key is stable (`cache::LOCAL_KEYS` is one fixed secret, not a
|
||||||
|
per-process `Keys::generate`), so caching a rumor twice leaves one row.
|
||||||
|
`cache.rs` carries the new `caching_the_same_rumor_twice_leaves_one_row` test.
|
||||||
|
3. `docs/concord-usage.md` updated; `concord` dropped `futures` and `nostr-memory`
|
||||||
|
and demoted `smol` to a dev-dependency; `community` gained `futures` and `smol`.
|
||||||
|
4. Gate, all green with the phase-1 transport still in place: `cargo test -p
|
||||||
|
concord -p community` (50 + 14), `cargo clippy -p concord -p community -p
|
||||||
|
community_ui --all-targets`, `cargo +nightly fmt --all --check` (no diff in the
|
||||||
|
touched files), `cargo check -p workspace --all-targets`. No behaviour changed
|
||||||
|
beyond item 2.
|
||||||
|
|
||||||
|
### Phase 2b — subscribe, then read the database
|
||||||
|
|
||||||
|
1. The pump (§2): route by subscription id for events, EOSE and CLOSED; batch
|
||||||
|
within `PUMP_WINDOW`; both wrap kinds; `auth-required` never a failure.
|
||||||
|
2. Page REQs (§3): the round registers a `PageReport` channel, installs one REQ
|
||||||
|
per page over the community's relays, awaits the pump's reports with
|
||||||
|
`PAGE_TIMEOUT`, reads the page from the database, and keeps the walk's rules
|
||||||
|
(`exhausted` earned, all-empty is `failed`, `newest_ms` only on a complete
|
||||||
|
round, skip the older pass when `saved.exhausted`).
|
||||||
|
3. The live REQ (§2, §5): both kinds, private channels, `Window::opening(saved)`
|
||||||
|
with `LIVE_REPLAY = 500`, kept alive across reconnects.
|
||||||
|
4. `cache::purge_expired` on the open and round cadence.
|
||||||
|
5. The fold issue (§9).
|
||||||
|
6. Gate: the phase-1 manual bar, plus a check against a dev relay that a warm open
|
||||||
|
sends one REQ per relay carrying a `since`, and a cold open sends one with no
|
||||||
|
`since` and pages down.
|
||||||
|
|
||||||
|
### Phase 3 — epochs and rekeys
|
||||||
|
|
||||||
|
§6: `HeldKey`/`HeldRoot` + `priors`, `retired_at` as a read cutoff, the rekey
|
||||||
|
watch over every held root, strict one-epoch-at-a-time adoption, re-subscribe +
|
||||||
|
`CatchUp` after a delivery, removed versus stranded states.
|
||||||
|
|
||||||
|
### Phase 4 — honest states and polish
|
||||||
|
|
||||||
|
§7 (empty/unreadable/failed in the panel, using the counts that already exist),
|
||||||
|
round progress in the UI, the `MIN_ROUND_INTERVAL = 30s` / `STALE_AFTER = 5min`
|
||||||
|
scheduler, and optional NIP-77 catch-up (`client.sync(filter)` where a relay
|
||||||
|
supports negentropy; "negentropy unsupported" means "fall back to the paged
|
||||||
|
walk", never "exhausted").
|
||||||
|
|
||||||
|
Each phase leaves the client consistent on its own. Phase 2a is invisible;
|
||||||
|
phase 2b is what makes "open a community" a subscription and a database read.
|
||||||
|
|
||||||
|
## Phase 1 status (landed)
|
||||||
|
|
||||||
|
What phase 1 delivered, and what phase 2 replaces:
|
||||||
|
|
||||||
|
- `Window`, `WrapPage`, `history::page`, `Walk`/`Walker`, the paged walk with an
|
||||||
|
exclusive page boundary, `ChannelCursor` + monotonic `merge` persisted in
|
||||||
|
`CommunityState.cursors`, `Community::sync_channel`/`timeline`, `cache_rumor`
|
||||||
|
counting what opened, and the panel's load-older/follow-the-tail work. All of
|
||||||
|
that survives; phase 2a moved it and phase 2b swaps its transport.
|
||||||
|
- **`fetch_events` is gone.** Phase 1 already replaced it with
|
||||||
|
subscribe-then-read-the-database: each page subscribed every answering relay to
|
||||||
|
one filter (`relay.subscribe(..).close_on(ExitOnEOSE + PAGE_TIMEOUT)`), waited
|
||||||
|
for that relay's `EndOfStoredEvents(id)`, then read the page with
|
||||||
|
`client.database().query(filter)`. Revision 2's correction is **where** that
|
||||||
|
lives (`community`, not `concord`) and **who** watches the notifications (the
|
||||||
|
pump, not a loop inside a page).
|
||||||
|
- `auth-required` was already excluded from the failure set in code, but
|
||||||
|
revision 1's prose described it wrongly ("ends that relay's wait as a failure
|
||||||
|
... the next round retries"). The SDK re-issues the REQ under the same id after
|
||||||
|
AUTH — for auto-closing subscriptions too — so the honest statement is: the
|
||||||
|
relay stays in the walk and the page waits for its resubscribed answer.
|
||||||
|
- Held epoch handling, rekeys, private planes, the second wrap kind, honest empty
|
||||||
|
states, the expired-row sweep, the stable cache key and the fold cost were all
|
||||||
|
open before phase 1 and are still open in §5, §6, §7 and §9.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Following the existing `MemoryDatabase` and `Walk`/`serve_page` test style:
|
||||||
|
|
||||||
|
- The walk: `history::page` continues past a page whose wraps none of them open;
|
||||||
|
`exhausted` requires a short page *after* history; an all-empty round is
|
||||||
|
`failed` and the next round re-asks the same region; `until` is exclusive and
|
||||||
|
the walk terminates; a channel that already holds its newest page still pages
|
||||||
|
older history; the bridge heals a hole; history pages across a rekey using
|
||||||
|
retained prior keys.
|
||||||
|
- The pump: an `EndOfStoredEvents(id)` settles exactly the page that owns `id`;
|
||||||
|
a non-auth CLOSED settles its relay as refused; an `auth-required` CLOSED
|
||||||
|
settles nothing; a 21059 event routes by subscription id; a burst within one
|
||||||
|
window produces one fold.
|
||||||
|
- The windows: `Window::opening(default)` has no `since`; `Window::opening(saved)`
|
||||||
|
starts at `newest - CURSOR_OVERLAP`; `Window::older_than` never includes the
|
||||||
|
boundary event.
|
||||||
|
- The subscription plan: a private channel's plane appears when the key is held
|
||||||
|
and is absent when it is not; the live filter asks for both wrap kinds; the
|
||||||
|
live REQ targets the community's relays only.
|
||||||
|
- The read path: the side-event budget folds an edit/delete/reaction older than
|
||||||
|
the row window onto its message.
|
||||||
|
- The fold: a new live wrap costs one decrypt, and a fold over a community with
|
||||||
|
5,000 cached rows does not re-open them.
|
||||||
|
- A stable cache key: caching the same rumor twice leaves one row (the phase-1
|
||||||
|
regression that duplicates a community's history per app run).
|
||||||
|
- GPUI (`TestAppContext`): prepending older rows preserves the scroll anchor; a
|
||||||
|
live message does not scroll a reader who is scrolled up; `has_more == false`
|
||||||
|
disables the load-older row. (No GPUI test harness exists in the repo yet.)
|
||||||
|
- Manual runs: two accounts, a channel with more than 200 messages, one account
|
||||||
|
offline long enough to miss a full page, one private channel, one rekey. The
|
||||||
|
acceptance bar is the reference behaviour: open a channel cold and see history
|
||||||
|
arrive in pages without touching the scrollbar, reopen it and see one REQ with a
|
||||||
|
`since` instead of a replay, and see the other account's message appear without
|
||||||
|
a reload.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Unread badges, notifications, message threads, pins, typing indicators and
|
||||||
|
presence (21059 wraps are wired for routing here, not for those features), file
|
||||||
|
and media rendering, moderation actions, and the community-management surfaces.
|
||||||
|
`crates/chat`'s DM path shares none of this code and is not touched; a later
|
||||||
|
change can lift the page walk/cursor into a shared module if DMs grow the same
|
||||||
|
paging.
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# Community sidebar
|
|
||||||
|
|
||||||
When a community is opened from the sidebar, the sidebar's content becomes the
|
|
||||||
community's own channel/member browser. Going back restores the normal tabs.
|
|
||||||
|
|
||||||
```
|
|
||||||
+------------------+--------------------------------+
|
|
||||||
| [<-] community | |
|
|
||||||
| banner | |
|
|
||||||
| v Channels 3 | messages |
|
|
||||||
| # general | |
|
|
||||||
| # random | |
|
|
||||||
| v Admins 1 | |
|
|
||||||
| @owner | |
|
|
||||||
| v Members 2 +--------------------------------+
|
|
||||||
| @alice | [ composer ] |
|
|
||||||
| @bob | |
|
|
||||||
+------------------+--------------------------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
## Current state
|
|
||||||
|
|
||||||
* `community_ui::CommunityPanel` renders its own 220px left column (channels +
|
|
||||||
members) next to the timeline.
|
|
||||||
* `Sidebar` renders the Recents/Chats/Communities tabs, and `Sidebar::open_community`
|
|
||||||
records the community as recent and asks `CommunityRegistry` to emit
|
|
||||||
`CommunityEvent::Open`, which `Workspace` turns into a center dock panel.
|
|
||||||
* `Community` already folds channels (`state().channels`), members, the control
|
|
||||||
roster (`control().roles`), the community icon, and the banner (`metadata.banner`).
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
### 1. Backend: community crate
|
|
||||||
|
|
||||||
`crates/community/src/community.rs`
|
|
||||||
|
|
||||||
* Add `CommunityEvent::Close(CommunityId)` and `CommunityEvent::Channel(CommunityId, ChannelId)`.
|
|
||||||
* Add `active: Option<ChannelId>` to `Community`, with:
|
|
||||||
* `pub fn active_channel(&self) -> Option<ChannelId>` — the selected channel,
|
|
||||||
defaulting to the first one.
|
|
||||||
* `pub fn set_active_channel(&mut self, ChannelId, &mut Context<Self>)` — stores
|
|
||||||
it and emits `Channel`.
|
|
||||||
* Add `banner: Option<PathBuf>` resolved from `metadata.banner` the same way the
|
|
||||||
icon already is, exposed as `pub fn banner(&self) -> Option<PathBuf>`.
|
|
||||||
|
|
||||||
`crates/community/src/lib.rs`
|
|
||||||
|
|
||||||
* Add `CommunityRegistry::emit_close(&mut self, CommunityId, &mut Window, &mut Context<Self>)`,
|
|
||||||
mirroring `emit_community`, emitting `Close`.
|
|
||||||
|
|
||||||
`crates/community/src/sync.rs`
|
|
||||||
|
|
||||||
* Rename `resolve_icon` to `resolve_image` (it takes any `ImageRef`).
|
|
||||||
|
|
||||||
### 2. `community_ui`: panel becomes timeline-only
|
|
||||||
|
|
||||||
* Drop the internal channel/member column and its helpers.
|
|
||||||
* Resolve the shown channel from `Community::active_channel` on every load and
|
|
||||||
reload, and subscribe to `CommunityEvent::Channel` to follow sidebar clicks
|
|
||||||
(deferred, because the community is being updated when it emits).
|
|
||||||
|
|
||||||
### 3. `workspace`: sidebar renders the community
|
|
||||||
|
|
||||||
`crates/workspace/src/sidebar/mod.rs`
|
|
||||||
|
|
||||||
* New state: `community: Option<WeakEntity<Community>>` and three section flags.
|
|
||||||
* `open_community` also sets `self.community`; new `close_community` clears it and
|
|
||||||
asks the registry to emit `Close`.
|
|
||||||
* `render` picks between the existing tabs and `render_community`.
|
|
||||||
* `render_user` keeps the account button as usual and adds a back button on the
|
|
||||||
right when a community is open.
|
|
||||||
* `render_community` pins the optional banner image and scrolls everything below
|
|
||||||
it: collapsible Channels, Admins (members where `control().roles.is_staff`), and
|
|
||||||
Members sections. Every row is a `TreeRow` — the component the sidebar's own
|
|
||||||
lists use — so height, padding, typography, hover, and selection match, and each
|
|
||||||
row is wrapped `flex_shrink_0` so the list scrolls instead of squashing.
|
|
||||||
* Observe the registry so member/roster changes re-render the lists.
|
|
||||||
|
|
||||||
`crates/workspace/src/lib.rs`
|
|
||||||
|
|
||||||
* Remember the opened `CommunityPanel` so `CommunityEvent::Close` can close it:
|
|
||||||
activate it, focus the group, then dispatch `ClosePanel`.
|
|
||||||
|
|
||||||
### 4. `community`: a list refresh keeps entities
|
|
||||||
|
|
||||||
`CommunityRegistry::track` used to rebuild every community, so an open panel and a
|
|
||||||
browsing sidebar went stale on any list signal — and `sync::load` merges join
|
|
||||||
material into a loaded `CommunityState`, so the state really can change. `track`
|
|
||||||
now keeps the entity for an id it still tracks and hands it the new state through
|
|
||||||
`Community::adopt`, which also cancels a fold in flight that would otherwise write
|
|
||||||
the pre-adoption state back. Observers move to a
|
|
||||||
`HashMap<CommunityId, Subscription>` so the survivors keep theirs, and the ids the
|
|
||||||
list dropped lose their observer and their synced key.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
Moderation, invites, community management, channel icons, unread badges, member
|
|
||||||
search, and persisting the selected channel across restarts.
|
|
||||||
+42
-15
@@ -29,11 +29,16 @@ vocabulary are shared substrate — every document calls them — so they live o
|
|||||||
| `cord05` | Invite bundles, links, the Direct Invite, the Invite List |
|
| `cord05` | Invite bundles, links, the Direct Invite, the Invite List |
|
||||||
| `cord06` | Key rotations, refounding, compaction, dissolution |
|
| `cord06` | Key rotations, refounding, compaction, dissolution |
|
||||||
| `derive` | Every frozen HKDF derivation and coordinate |
|
| `derive` | Every frozen HKDF derivation and coordinate |
|
||||||
| `store` | Local rumor cache, the community state document, relay paging |
|
| `state` | The community state document and its pure folds — no I/O |
|
||||||
|
|
||||||
`CommunityId`, `ChannelId`, `RoleId`, `Epoch` and `Extra` (crate-internal) come from
|
`CommunityId`, `ChannelId`, `RoleId`, `Epoch` and `Extra` (crate-internal) come from
|
||||||
the private `types` module and are re-exported at the crate root.
|
the private `types` module and are re-exported at the crate root.
|
||||||
|
|
||||||
|
Nothing in `concord` touches a relay or a database. The local rumor cache, the
|
||||||
|
state documents and the relay paging walk live one crate up, in
|
||||||
|
`community::cache` and `community::history`, and are what a client actually calls
|
||||||
|
(read on below).
|
||||||
|
|
||||||
CORD-07 (audio/video) is unimplemented. CORD-08's timer has no file of its own: it
|
CORD-07 (audio/video) is unimplemented. CORD-08's timer has no file of its own: it
|
||||||
lives in the metadata it reads (`cord02`) and the fold it filters (`cord03`).
|
lives in the metadata it reads (`cord02`) and the fold it filters (`cord03`).
|
||||||
|
|
||||||
@@ -44,7 +49,8 @@ Read `CommunityId` as "this community", `ChannelId` as "this channel", `Epoch` a
|
|||||||
|
|
||||||
```rust
|
```rust
|
||||||
use concord::cord02::{self, CommunityMetadata};
|
use concord::cord02::{self, CommunityMetadata};
|
||||||
use concord::store::{self, CommunityState, save_state};
|
use concord::state::CommunityState;
|
||||||
|
use community::cache::save_state;
|
||||||
|
|
||||||
let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() };
|
let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() };
|
||||||
let minted = cord02::genesis(&owner_keys, &metadata, now_secs).await?;
|
let minted = cord02::genesis(&owner_keys, &metadata, now_secs).await?;
|
||||||
@@ -187,6 +193,7 @@ about an existing `EventId` rather than a mutation.
|
|||||||
|
|
||||||
```rust
|
```rust
|
||||||
use concord::cord03::{self, fold, plane_keys};
|
use concord::cord03::{self, fold, plane_keys};
|
||||||
|
use community::cache;
|
||||||
|
|
||||||
let planes = plane_keys(&held, &channel)?; // &[(Epoch, secret)]
|
let planes = plane_keys(&held, &channel)?; // &[(Epoch, secret)]
|
||||||
let mut rumors = Vec::new();
|
let mut rumors = Vec::new();
|
||||||
@@ -198,7 +205,7 @@ for wrap in &wraps {
|
|||||||
let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else {
|
let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
store::cache_rumor(&client, &channel, &opened).await?;
|
cache::cache_rumor(&client, &channel, &opened).await?;
|
||||||
rumors.push(rumor);
|
rumors.push(rumor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,15 +222,35 @@ let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| {
|
|||||||
Relay history pages through the local cache:
|
Relay history pages through the local cache:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let page = store::backfill(client, &channel, &held, until, 50).await?;
|
use community::history::{self, Window};
|
||||||
let cached = store::query_rumors(&client, &channel, None, 50).await?;
|
|
||||||
|
let page = history::page(client, &channel, &held, &relays, Window::newest(), 20, 50).await?;
|
||||||
|
let cached = cache::query_rumors(&client, &channel, None, 50, Some(&cord03::ROW_KINDS)).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
`backfill` walks newest-first across every held epoch, caches what it opens, and
|
A relay is only ever *asked*: `history::page` subscribes each one to the page's
|
||||||
stops on a short page. `query_rumors` is the read path when the group keys are
|
filter (which is what makes the client verify a wrap, deduplicate it and persist
|
||||||
gone. Run `store::purge_expired(client, &channel, now)` on the same cadence as
|
it), waits for EOSE, and then reads the page back out of the local database —
|
||||||
any other local sweep — the timer is cooperative, so the local store is the
|
no wrap is ever consumed straight off the wire, and `fetch_events` is not used
|
||||||
artifact that has to forget.
|
anywhere. The wrap lands in the client's shared event store through that
|
||||||
|
ordinary ingest path; `cache_rumor` then puts the decrypted rumor beside it, and
|
||||||
|
only the rumor is ever served to a reader.
|
||||||
|
|
||||||
|
A page ends on EOSE. A CLOSED ends it as unanswered, *except* NIP-42's
|
||||||
|
`auth-required`: the SDK re-issues that REQ under the same subscription id once
|
||||||
|
the handshake completes, so the page waits for the resubscribed answer rather
|
||||||
|
than writing off a relay that only wanted to authenticate.
|
||||||
|
|
||||||
|
`history::page` walks newest-first across every held epoch, caches what it opens, and
|
||||||
|
reports what it saw: `oldest_ms`/`newest_ms` feed the caller's `ChannelCursor`,
|
||||||
|
`exhausted` is earned only by a short page *after* history was seen, and an
|
||||||
|
all-empty answer sets `failed` so a later round re-asks instead of sealing the
|
||||||
|
channel at "no more history". Page down with `Window::older_than(seen.oldest_ms)`,
|
||||||
|
and read the region between two cursors with `Window::between(..)`. `query_rumors`
|
||||||
|
is the read path when the group keys are gone; pass `kinds` to budget rows apart
|
||||||
|
from the events that only decorate them. Run `cache::purge_expired(client, &channel, now)`
|
||||||
|
on the same cadence as any other local sweep — the timer is cooperative, so the
|
||||||
|
local store is the artifact that has to forget.
|
||||||
|
|
||||||
`ChatAction::TimerNotice { seconds }` is a policy notice, not a message: render it
|
`ChatAction::TimerNotice { seconds }` is a policy notice, not a message: render it
|
||||||
as an inline row only when its author passes
|
as an inline row only when its author passes
|
||||||
@@ -419,7 +446,7 @@ A member's own memberships, synced across their devices:
|
|||||||
```rust
|
```rust
|
||||||
use concord::cord02::list;
|
use concord::cord02::list;
|
||||||
|
|
||||||
let entry = concord::store::list_entry(&state, &metadata.name); // state → §8 material
|
let entry = concord::state::list_entry(&state, &metadata.name); // state → §8 material
|
||||||
let held = list::parse_list_event(&my_keys, &event).await?; // validates the d tag
|
let held = list::parse_list_event(&my_keys, &event).await?; // validates the d tag
|
||||||
let mine = held.joined(entry); // community_id-keyed union
|
let mine = held.joined(entry); // community_id-keyed union
|
||||||
let event = list::build_list_event(&my_keys, &mine, 0, now_secs).await?; // kind 33302, d = 0
|
let event = list::build_list_event(&my_keys, &mine, 0, now_secs).await?; // kind 33302, d = 0
|
||||||
@@ -431,7 +458,7 @@ locally *before* it resolves targets, so the fragment is available to the
|
|||||||
`concord/list` read path even if every relay is unreachable.
|
`concord/list` read path even if every relay is unreachable.
|
||||||
|
|
||||||
`join_material(&invite, control_root)` makes the §8 material from a CORD-05
|
`join_material(&invite, control_root)` makes the §8 material from a CORD-05
|
||||||
invite; `store::list_entry(state, name)` makes it from a `CommunityState`, and is
|
invite; `concord::state::list_entry(state, name)` makes it from a `CommunityState`, and is
|
||||||
what a write path uses after a create, join or rename. `joined` and `tombstoned`
|
what a write path uses after a create, join or rename. `joined` and `tombstoned`
|
||||||
are the two mutations: both are `community_id`-keyed unions, so neither an append
|
are the two mutations: both are `community_id`-keyed unions, so neither an append
|
||||||
nor a leave can lose a membership the other writer has.
|
nor a leave can lose a membership the other writer has.
|
||||||
@@ -538,7 +565,7 @@ self.ingress = Some(cx.background_spawn(async move {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let (opened, rumor) = cord03::open(wrap, &plane.group, &plane.channel, plane.epoch)?;
|
let (opened, rumor) = cord03::open(wrap, &plane.group, &plane.channel, plane.epoch)?;
|
||||||
store::cache_rumor(&client, &plane.channel, &opened).await?;
|
cache::cache_rumor(&client, &plane.channel, &opened).await?;
|
||||||
signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?;
|
signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -553,7 +580,7 @@ self.consumer = Some(cx.spawn(async move |this, cx| {
|
|||||||
}));
|
}));
|
||||||
```
|
```
|
||||||
|
|
||||||
- Every store function takes the `&Client` and reaches the database through
|
- Every cache function takes the `&Client` and reaches the database through
|
||||||
`client.database()`, so clone the `Client` into the background task.
|
`client.database()`, so clone the `Client` into the background task.
|
||||||
- Keep long-lived tasks in fields — dropping a `Task` cancels it. Assign `None`
|
- Keep long-lived tasks in fields — dropping a `Task` cancels it. Assign `None`
|
||||||
to an `Option<Task<_>>` before respawning it; a signer change replaces both
|
to an `Option<Task<_>>` before respawning it; a signer change replaces both
|
||||||
@@ -627,7 +654,7 @@ client.subscribe(filter).with_id(sub_id).await?;
|
|||||||
app `UniversalSigner` both work. The NIP-59 paths (`build_direct_invite`,
|
app `UniversalSigner` both work. The NIP-59 paths (`build_direct_invite`,
|
||||||
`unwrap_direct_invite`) stay `Sized` because the SDK's gift-wrap helpers are.
|
`unwrap_direct_invite`) stay `Sized` because the SDK's gift-wrap helpers are.
|
||||||
Group-key and locally-held-secret writers (`cord01` wrap functions,
|
Group-key and locally-held-secret writers (`cord01` wrap functions,
|
||||||
`cord05::build_bundle_event`, `store`) still take the raw key material they
|
`cord05::build_bundle_event`, `community::cache`) still take the raw key material they
|
||||||
genuinely need.
|
genuinely need.
|
||||||
- **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as
|
- **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as
|
||||||
a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so
|
a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so
|
||||||
|
|||||||
Reference in New Issue
Block a user