update
This commit is contained in:
Generated
+1
@@ -1318,6 +1318,7 @@ dependencies = [
|
||||
"data-encoding",
|
||||
"hkdf",
|
||||
"hmac 0.12.1",
|
||||
"log",
|
||||
"nostr",
|
||||
"nostr-memory",
|
||||
"nostr-sdk",
|
||||
|
||||
@@ -107,8 +107,9 @@ impl CommunityRegistry {
|
||||
/// Create a community owned by the current account and begin tracking it.
|
||||
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let current_user = nostr.read(cx).current_user();
|
||||
|
||||
if nostr.read(cx).current_user().is_none() {
|
||||
if current_user.is_none() {
|
||||
cx.emit(CommunityEvent::Error(
|
||||
"cannot create a community without an account".to_owned(),
|
||||
));
|
||||
|
||||
@@ -14,9 +14,6 @@ use concord::{ChannelId, CommunityId, Epoch, GroupKey};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::UniversalSigner;
|
||||
|
||||
const SUBSCRIPTION_PREFIX: &str = "concord/";
|
||||
const STATE_PREFIX: &str = "concord/";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PlaneKind {
|
||||
Control(Epoch),
|
||||
@@ -77,13 +74,13 @@ pub fn subscription_filter(planes: &[Plane]) -> Filter {
|
||||
}
|
||||
|
||||
pub fn subscription_id(id: &CommunityId) -> SubscriptionId {
|
||||
SubscriptionId::new(format!("{SUBSCRIPTION_PREFIX}{}", id.to_hex()))
|
||||
SubscriptionId::new(format!("{}{}", store::STATE_PREFIX, id.to_hex()))
|
||||
}
|
||||
|
||||
pub fn community_of(subscription_id: &SubscriptionId) -> Option<CommunityId> {
|
||||
subscription_id
|
||||
.as_str()
|
||||
.strip_prefix(SUBSCRIPTION_PREFIX)?
|
||||
.strip_prefix(store::STATE_PREFIX)?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
@@ -130,30 +127,7 @@ pub async fn load(
|
||||
signer: &UniversalSigner,
|
||||
self_pk: PublicKey,
|
||||
) -> 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),
|
||||
}
|
||||
}
|
||||
let mut states = store::load_states(client).await?;
|
||||
|
||||
if let Some(list) = load_list(client, signer, self_pk).await? {
|
||||
states.retain(|state| list.is_live(&state.id));
|
||||
@@ -162,12 +136,6 @@ pub async fn load(
|
||||
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()
|
||||
}
|
||||
|
||||
async fn load_list(
|
||||
client: &Client,
|
||||
signer: &UniversalSigner,
|
||||
|
||||
@@ -17,6 +17,7 @@ rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
nostr-memory.workspace = true
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::cord01::{
|
||||
KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap,
|
||||
wrap_seal,
|
||||
};
|
||||
use crate::cord04::{
|
||||
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
|
||||
KIND_WRAP, OpenedStream, SealForm, build_rumor_ms, build_seal, open_wrap, wrap_seal,
|
||||
};
|
||||
use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag};
|
||||
pub use crate::cords::rumor::RumorError as GuestbookError;
|
||||
use crate::cords::rumor::{optional_citation, pubkey, required, value};
|
||||
use crate::{GroupKey, decode_hex_32};
|
||||
|
||||
pub const KIND_JOIN_LEAVE: u16 = 3306;
|
||||
@@ -29,41 +27,6 @@ const TAG_CONTENT: &str = "content";
|
||||
const CONTENT_JOIN: &str = "join";
|
||||
const CONTENT_LEAVE: &str = "leave";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum GuestbookError {
|
||||
Stream(StreamError),
|
||||
NotEncryptedSealed,
|
||||
UnknownKind(u16),
|
||||
MissingTag(&'static str),
|
||||
DuplicateTag(&'static str),
|
||||
BadTag(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for GuestbookError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
GuestbookError::Stream(error) => write!(f, "stream: {error}"),
|
||||
GuestbookError::NotEncryptedSealed => {
|
||||
write!(f, "guestbook rumor must ride an encrypted seal")
|
||||
}
|
||||
GuestbookError::UnknownKind(kind) => {
|
||||
write!(f, "not a guestbook rumor kind: {kind}")
|
||||
}
|
||||
GuestbookError::MissingTag(name) => write!(f, "missing guestbook tag: {name}"),
|
||||
GuestbookError::DuplicateTag(name) => write!(f, "duplicate guestbook tag: {name}"),
|
||||
GuestbookError::BadTag(name) => write!(f, "malformed guestbook tag: {name}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for GuestbookError {}
|
||||
|
||||
impl From<StreamError> for GuestbookError {
|
||||
fn from(error: StreamError) -> Self {
|
||||
GuestbookError::Stream(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GuestbookEntry {
|
||||
Join {
|
||||
@@ -469,73 +432,21 @@ fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), Guestboo
|
||||
Ok((snapshot_id, (index, total)))
|
||||
}
|
||||
|
||||
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, GuestbookError> {
|
||||
let Some(fields) = tag(rumor, TAG_CITATION)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
citation_from(fields)
|
||||
.map(Some)
|
||||
.ok_or(GuestbookError::BadTag(TAG_CITATION))
|
||||
}
|
||||
|
||||
fn decimal(raw: &str) -> Result<u32, GuestbookError> {
|
||||
canonical_decimal(raw)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.ok_or(GuestbookError::BadTag(TAG_SNAP))
|
||||
}
|
||||
|
||||
fn required<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<&'a [String], GuestbookError> {
|
||||
tag(rumor, name)?.ok_or(GuestbookError::MissingTag(name))
|
||||
}
|
||||
|
||||
fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
|
||||
pubkey(value(required(rumor, name)?, name)?, name)
|
||||
}
|
||||
|
||||
fn tag<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<&'a [String]>, GuestbookError> {
|
||||
let mut found: Option<&[String]> = None;
|
||||
|
||||
for candidate in rumor.tags.iter() {
|
||||
let fields = candidate.as_slice();
|
||||
|
||||
if fields.first().map(String::as_str) != Some(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if found.is_some() {
|
||||
return Err(GuestbookError::DuplicateTag(name));
|
||||
}
|
||||
|
||||
found = Some(fields);
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, GuestbookError> {
|
||||
fields
|
||||
.get(1)
|
||||
.map(String::as_str)
|
||||
.ok_or(GuestbookError::BadTag(name))
|
||||
}
|
||||
|
||||
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, GuestbookError> {
|
||||
let bytes = decode_hex_32(hex).map_err(|_| GuestbookError::BadTag(name))?;
|
||||
|
||||
PublicKey::from_slice(&bytes).map_err(|_| GuestbookError::BadTag(name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cord01::build_rumor_secs;
|
||||
use crate::cord01::{StreamError, build_rumor_secs};
|
||||
use crate::cord04::TAG_CITATION;
|
||||
use crate::derive::guestbook_group_key;
|
||||
use crate::{CommunityId, Epoch};
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::cord01::{
|
||||
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms,
|
||||
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict,
|
||||
wrap_seal,
|
||||
};
|
||||
use crate::cord04::{
|
||||
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
|
||||
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, build_rumor_ms, build_seal,
|
||||
channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, wrap_seal,
|
||||
};
|
||||
use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag};
|
||||
pub use crate::cords::rumor::RumorError as ChatError;
|
||||
use crate::cords::rumor::{optional_citation, pubkey, tag, value};
|
||||
use crate::derive::channel_group_key;
|
||||
use crate::{ChannelId, Epoch, GroupKey, decode_hex_32};
|
||||
|
||||
@@ -36,42 +34,6 @@ const TAG_TARGET_AUTHOR: &str = "p";
|
||||
const TAG_EXPIRATION: &str = "expiration";
|
||||
const TAG_TIMER: &str = "timer";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ChatError {
|
||||
Stream(StreamError),
|
||||
NotEncryptedSealed,
|
||||
UnknownKind(u16),
|
||||
MissingTag(&'static str),
|
||||
DuplicateTag(&'static str),
|
||||
BadTag(&'static str),
|
||||
/// Neither a delete nor a timer notice may be erased by the policy it carries.
|
||||
ExemptExpiration,
|
||||
}
|
||||
|
||||
impl fmt::Display for ChatError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ChatError::Stream(error) => write!(f, "stream: {error}"),
|
||||
ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"),
|
||||
ChatError::UnknownKind(kind) => write!(f, "not a chat rumor kind: {kind}"),
|
||||
ChatError::MissingTag(name) => write!(f, "missing chat tag: {name}"),
|
||||
ChatError::DuplicateTag(name) => write!(f, "duplicate chat tag: {name}"),
|
||||
ChatError::BadTag(name) => write!(f, "malformed chat tag: {name}"),
|
||||
ChatError::ExemptExpiration => {
|
||||
write!(f, "a delete or timer notice must not carry an expiration")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ChatError {}
|
||||
|
||||
impl From<StreamError> for ChatError {
|
||||
fn from(error: StreamError) -> Self {
|
||||
ChatError::Stream(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// A chat event another chat event refers to: a quote, a comment's parent, a reaction's target.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ReplyRef {
|
||||
@@ -579,16 +541,6 @@ fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<u16
|
||||
.map_err(|_| ChatError::BadTag(name))
|
||||
}
|
||||
|
||||
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, ChatError> {
|
||||
let Some(fields) = tag(rumor, TAG_CITATION)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
citation_from(fields)
|
||||
.map(Some)
|
||||
.ok_or(ChatError::BadTag(TAG_CITATION))
|
||||
}
|
||||
|
||||
pub fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
|
||||
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
|
||||
return Ok(None);
|
||||
@@ -614,51 +566,16 @@ fn reply_tag(name: &str, reply: &ReplyRef) -> Tag {
|
||||
)
|
||||
}
|
||||
|
||||
fn tag<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<&'a [String]>, ChatError> {
|
||||
let mut found: Option<&[String]> = None;
|
||||
|
||||
for candidate in rumor.tags.iter() {
|
||||
let fields = candidate.as_slice();
|
||||
|
||||
if fields.first().map(String::as_str) != Some(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if found.is_some() {
|
||||
return Err(ChatError::DuplicateTag(name));
|
||||
}
|
||||
|
||||
found = Some(fields);
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
|
||||
fields
|
||||
.get(1)
|
||||
.map(String::as_str)
|
||||
.ok_or(ChatError::BadTag(name))
|
||||
}
|
||||
|
||||
fn hex_id(fields: &[String], name: &'static str) -> Result<EventId, ChatError> {
|
||||
let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?;
|
||||
|
||||
EventId::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
|
||||
}
|
||||
|
||||
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, ChatError> {
|
||||
let bytes = decode_hex_32(hex).map_err(|_| ChatError::BadTag(name))?;
|
||||
|
||||
PublicKey::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cord01::StreamError;
|
||||
|
||||
const SECRET: [u8; 32] = [0x2du8; 32];
|
||||
const AT: u64 = 1_700_000_000_417;
|
||||
|
||||
@@ -121,7 +121,7 @@ fn signing_bytes(
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn edition_hash(
|
||||
fn edition_hash(
|
||||
entity: &[u8; 32],
|
||||
version: u64,
|
||||
prev: Option<&[u8; 32]>,
|
||||
@@ -246,14 +246,14 @@ impl From<&ParsedEdition> for EditionMeta {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct FoldResult {
|
||||
struct FoldResult {
|
||||
pub head: Option<usize>,
|
||||
pub gap: bool,
|
||||
pub anchored: bool,
|
||||
}
|
||||
|
||||
/// The highest version whose chain is intact, given a held floor.
|
||||
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
|
||||
fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
|
||||
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
|
||||
|
||||
for (index, edition) in editions.iter().enumerate() {
|
||||
@@ -311,7 +311,7 @@ pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>)
|
||||
}
|
||||
|
||||
/// The highest version overall, ignoring contiguity.
|
||||
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
||||
fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
||||
editions
|
||||
.iter()
|
||||
.enumerate()
|
||||
|
||||
@@ -100,7 +100,7 @@ pub struct Role {
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn parse(content: &str) -> Option<Self> {
|
||||
fn parse(content: &str) -> Option<Self> {
|
||||
serde_json::from_str(content).ok()
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ pub struct Grant {
|
||||
}
|
||||
|
||||
impl Grant {
|
||||
pub fn parse(content: &str) -> Option<Self> {
|
||||
fn parse(content: &str) -> Option<Self> {
|
||||
serde_json::from_str(content).ok()
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ impl Grant {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_banlist(content: &str) -> Option<Vec<PublicKey>> {
|
||||
fn parse_banlist(content: &str) -> Option<Vec<PublicKey>> {
|
||||
let entries: Vec<String> = serde_json::from_str(content).ok()?;
|
||||
let mut banned = Vec::with_capacity(entries.len());
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
mod rumor;
|
||||
|
||||
pub mod cord01;
|
||||
pub mod cord02;
|
||||
pub mod cord03;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
use std::fmt;
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::cord01::StreamError;
|
||||
use crate::cord04::{AuthorityCitation, TAG_CITATION, citation_from};
|
||||
use crate::decode_hex_32;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RumorError {
|
||||
Stream(StreamError),
|
||||
NotEncryptedSealed,
|
||||
UnknownKind(u16),
|
||||
MissingTag(&'static str),
|
||||
DuplicateTag(&'static str),
|
||||
BadTag(&'static str),
|
||||
/// Neither a delete nor a timer notice may be erased by the policy it carries.
|
||||
ExemptExpiration,
|
||||
}
|
||||
|
||||
impl fmt::Display for RumorError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
RumorError::Stream(error) => write!(f, "stream: {error}"),
|
||||
RumorError::NotEncryptedSealed => write!(f, "rumor must ride an encrypted seal"),
|
||||
RumorError::UnknownKind(kind) => write!(f, "not a rumor kind: {kind}"),
|
||||
RumorError::MissingTag(name) => write!(f, "missing tag: {name}"),
|
||||
RumorError::DuplicateTag(name) => write!(f, "duplicate tag: {name}"),
|
||||
RumorError::BadTag(name) => write!(f, "malformed tag: {name}"),
|
||||
RumorError::ExemptExpiration => {
|
||||
write!(f, "a delete or timer notice must not carry an expiration")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RumorError {}
|
||||
|
||||
impl From<StreamError> for RumorError {
|
||||
fn from(error: StreamError) -> Self {
|
||||
RumorError::Stream(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tag<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<&'a [String]>, RumorError> {
|
||||
let mut found: Option<&[String]> = None;
|
||||
|
||||
for candidate in rumor.tags.iter() {
|
||||
let fields = candidate.as_slice();
|
||||
|
||||
if fields.first().map(String::as_str) != Some(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if found.is_some() {
|
||||
return Err(RumorError::DuplicateTag(name));
|
||||
}
|
||||
|
||||
found = Some(fields);
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub fn required<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<&'a [String], RumorError> {
|
||||
tag(rumor, name)?.ok_or(RumorError::MissingTag(name))
|
||||
}
|
||||
|
||||
pub fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, RumorError> {
|
||||
fields
|
||||
.get(1)
|
||||
.map(String::as_str)
|
||||
.ok_or(RumorError::BadTag(name))
|
||||
}
|
||||
|
||||
pub fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, RumorError> {
|
||||
let bytes = decode_hex_32(hex).map_err(|_| RumorError::BadTag(name))?;
|
||||
|
||||
PublicKey::from_slice(&bytes).map_err(|_| RumorError::BadTag(name))
|
||||
}
|
||||
|
||||
pub fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, RumorError> {
|
||||
let Some(fields) = tag(rumor, TAG_CITATION)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
citation_from(fields)
|
||||
.map(Some)
|
||||
.ok_or(RumorError::BadTag(TAG_CITATION))
|
||||
}
|
||||
@@ -23,7 +23,8 @@ const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
|
||||
const MARK_VALUE: &str = "concord";
|
||||
const WRAP_TAG: &str = "e";
|
||||
const KIND_TAG: &str = "k";
|
||||
const STATE_PREFIX: &str = "concord/";
|
||||
/// 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(
|
||||
@@ -91,7 +92,7 @@ pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp)
|
||||
}
|
||||
|
||||
pub async fn query_rumors(
|
||||
database: &dyn NostrDatabase,
|
||||
client: &Client,
|
||||
channel: &ChannelId,
|
||||
until: Option<Timestamp>,
|
||||
limit: usize,
|
||||
@@ -106,7 +107,7 @@ pub async fn query_rumors(
|
||||
}
|
||||
|
||||
let mut newest: BTreeMap<String, Event> = BTreeMap::new();
|
||||
for event in database.query(filter).await? {
|
||||
for event in client.database().query(filter).await? {
|
||||
let Some(rumor_id) = event.tags.identifier() else {
|
||||
continue;
|
||||
};
|
||||
@@ -297,21 +298,54 @@ pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
|
||||
where
|
||||
D: NostrDatabase + ?Sized,
|
||||
{
|
||||
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 database.query(filter).await?.into_iter().next() {
|
||||
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,
|
||||
@@ -492,4 +526,43 @@ mod tests {
|
||||
["after the rekey", "still before", "before the rekey"]
|
||||
);
|
||||
}
|
||||
|
||||
#[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]),
|
||||
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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ pub struct CommunityEntry {
|
||||
}
|
||||
|
||||
pub fn dummy_communities() -> &'static [CommunityEntry] {
|
||||
// TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md.
|
||||
// TODO(concord): replace with CommunityRegistry communities, see docs/concord-usage.md.
|
||||
&[
|
||||
CommunityEntry {
|
||||
name: "Coop Contributors",
|
||||
|
||||
@@ -251,25 +251,36 @@ Validation: `cargo test -p concord` — 46 passed, 0 failed (the 80-blob
|
||||
the base64 record). `cargo clippy -p concord --all-targets` and
|
||||
`cargo fmt -p concord --check` are clean.
|
||||
|
||||
### Phase 4 — duplication and hygiene (independent, low risk)
|
||||
### Phase 4 — duplication and hygiene (independent, low risk) — DONE
|
||||
|
||||
1. Add `store::load_states(client)` and delete the app-side state-document scan
|
||||
(`crates/community/src/sync.rs:100-137`).
|
||||
2. Export the `concord/` state prefix from concord; delete the app-side copies
|
||||
(`store.rs:26`, `sync.rs:15-16`).
|
||||
3. Collapse the duplicated tag parsers (`cord03.rs:614-654` vs
|
||||
`guestbook.rs:496-530`) and the identical `ChatError`/`GuestbookError`
|
||||
enums.
|
||||
4. Remove never-varied parameters where the change is local: `banned_at` from
|
||||
`complete_memberlist` (doc admits "empty today"), `cache_rumor -> Result<()>`
|
||||
once nothing reads the bool, `snapshot_authority`/`ephemeral`/`query_rumors
|
||||
(until)` if no scheduled flow needs them.
|
||||
5. Tighten visibility of internal-only `pub` items in `cord04`
|
||||
(`edition_hash`, `fold`, `FoldResult`, `bootstrap_head`, `HeadSelection`,
|
||||
`parse_banlist`, `Role::parse`, `Grant::parse`).
|
||||
6. Fix doc drift: `backfill` arity (`docs/concord-usage.md:212`), `save_state`
|
||||
parameter (`:487`), `init` signature (`:431-432`), and refresh the "Not wired
|
||||
up yet" section (`:528-545`) once Phase 2 lands.
|
||||
1. DONE — `store::load_states(client)` added (with a direct `store` test), the
|
||||
app-side state-document scan in `sync::load` is gone.
|
||||
2. DONE — `store::STATE_PREFIX` is public; the app-side `concord/` literals are
|
||||
gone, and subscription ids reuse the exported prefix.
|
||||
3. DONE — the shared rumor tag readers and error live in a new `cords::rumor`
|
||||
module (`RumorError`, `tag`, `required`, `value`, `pubkey`,
|
||||
`optional_citation`), re-exported as `cord03::ChatError` and
|
||||
`cord02::guestbook::GuestbookError`. `cord06` keeps its own narrower
|
||||
`RekeyError`, which the plan scoped out.
|
||||
4. RETAINED — none of the "never-varied parameters" were removed. Each is
|
||||
load-bearing for a flow the fold or a writer already implements (D1):
|
||||
- `complete_memberlist`'s `banned_at` is read by the fold and is exercised
|
||||
with a non-empty map by `join_leave_kick_and_snapshot_converge_to_one_memberlist`;
|
||||
`docs/concord-usage.md` already promises to fill it once the banlist head's
|
||||
timestamp is plumbed through.
|
||||
- `cache_rumor -> Result<bool>` is read by `backfill` to drop expired rumors.
|
||||
- `coalesce`'s `snapshot_authority` gates which snapshots apply; passing
|
||||
`None` today is a policy, not a dead parameter.
|
||||
- `seal_rumor(ephemeral)` and the `until` cursors on `backfill`/`query_rumors`
|
||||
select protocol modes and paging.
|
||||
5. DONE — tightened `cord04` visibility: `edition_hash`, `fold`, `FoldResult`,
|
||||
`bootstrap_head`, `parse_banlist`, `Role::parse` and `Grant::parse` are no
|
||||
longer `pub`. `HeadSelection` stays `pub` because the public `fold_head`
|
||||
returns it.
|
||||
6. DONE — doc drift fixed: the store takes `&Client` throughout (including
|
||||
`load_state`/`load_states`/`query_rumors`, not just the writers), `backfill`
|
||||
arity, `set_pin_list`'s missing `.await`, the GPUI `init` signature and
|
||||
registry names, and the "Not wired up yet" registry bullet.
|
||||
|
||||
---
|
||||
|
||||
@@ -284,7 +295,7 @@ Per D1 these stay, but they should be understood as unwired, not live:
|
||||
| `cord04::pins` | ~550 | none |
|
||||
| `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` |
|
||||
| guestbook / list write paths | ~240 | `open`, `coalesce`, `complete_memberlist`, `is_live` |
|
||||
| `store` paging / purge / query / load_state | ~180 | `cache_rumor`, `save_state` |
|
||||
| `store` paging / purge / query / load_state(s) | ~180 | `cache_rumor`, `save_state`, `load_states` |
|
||||
|
||||
Truly unreferenced even by tests (safe candidates, but kept per D1):
|
||||
`CommunityInvite::expired`, `GroupKey::pk_hex`, `From<[u8; 32]>` impls,
|
||||
@@ -310,6 +321,9 @@ Truly unreferenced even by tests (safe candidates, but kept per D1):
|
||||
- Phase 2 adds the app-level test: seed a `CommunityState` via
|
||||
`store::save_state`, drive `CommunityRegistry`, assert a subscription is made
|
||||
and an inbound wrap folds into the community.
|
||||
- Phase 4: `cargo test -p concord -p community` (47 + 1 passed),
|
||||
`cargo clippy -p concord -p community --all-targets`, and
|
||||
`cargo fmt -p concord -p community --check` are all clean.
|
||||
|
||||
## 6. Immediate unblock
|
||||
|
||||
|
||||
+25
-22
@@ -73,7 +73,7 @@ let editions: Vec<ParsedEdition> = minted
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?;
|
||||
save_state(database, &state).await?;
|
||||
save_state(&client, &state).await?;
|
||||
```
|
||||
|
||||
Put the community's relay list into `state.relays` and add those relays to the
|
||||
@@ -192,7 +192,7 @@ for wrap in &wraps {
|
||||
let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else {
|
||||
continue;
|
||||
};
|
||||
store::cache_rumor(database, &channel, &opened).await?;
|
||||
store::cache_rumor(&client, &channel, &opened).await?;
|
||||
rumors.push(rumor);
|
||||
}
|
||||
|
||||
@@ -209,13 +209,13 @@ let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| {
|
||||
Relay history pages through the local cache:
|
||||
|
||||
```rust
|
||||
let page = store::backfill(client, database, &channel, &held, until, 50).await?;
|
||||
let cached = store::query_rumors(database, &channel, None, 50).await?;
|
||||
let page = store::backfill(client, &channel, &held, until, 50).await?;
|
||||
let cached = store::query_rumors(&client, &channel, None, 50).await?;
|
||||
```
|
||||
|
||||
`backfill` walks newest-first across every held epoch, caches what it opens, and
|
||||
stops on a short page. `query_rumors` is the read path when the group keys are
|
||||
gone. Run `store::purge_expired(database, &channel, now)` on the same cadence as
|
||||
gone. Run `store::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.
|
||||
|
||||
@@ -275,7 +275,7 @@ let head_content = control.pin_content(&community_id, &channel).unwrap_or("");
|
||||
let read = cord04::pins::read_list(head_content, |epoch| channel_group_key(&root, &channel, epoch).ok());
|
||||
let content = cord04::pins::publishable(&read, channel_is_private, &plane, epoch)?;
|
||||
let (wrap, _) = writer.set_pin_list(
|
||||
&my_keys, &community_id, &channel, &content, head, citation, now_secs)?;
|
||||
&my_keys, &community_id, &channel, &content, head, citation, now_secs).await?;
|
||||
```
|
||||
|
||||
Reading is verification: `read_list` decodes either content form (public, or
|
||||
@@ -428,7 +428,8 @@ the NIP-44 size cap, both protocol constants.
|
||||
|
||||
## GPUI integration
|
||||
|
||||
`crates/concord` stays GPUI-free. The UI layer adds a registry global and one
|
||||
`crates/concord` stays GPUI-free; the registry and sync engine live in
|
||||
`crates/community`. That layer adds a registry global and one
|
||||
entity per community, and moves every decrypt, verification, fold and I/O off
|
||||
the foreground thread.
|
||||
|
||||
@@ -437,13 +438,13 @@ the foreground thread.
|
||||
Same shape as `ChatRegistry`:
|
||||
|
||||
```rust
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
ConcordRegistry::set_global(cx.new(|cx| ConcordRegistry::new(window, cx)), cx);
|
||||
pub fn init(cx: &mut App) {
|
||||
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
|
||||
}
|
||||
|
||||
impl ConcordRegistry {
|
||||
impl CommunityRegistry {
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalConcordRegistry>().0.clone()
|
||||
cx.global::<GlobalCommunityRegistry>().0.clone()
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -452,8 +453,8 @@ Call it after `cord03::init` in `desktop/src/main.rs` and `web/src/lib.rs`, and
|
||||
subscribe to `NostrRegistry` for `SignerChanged` so the communities reset with
|
||||
the account.
|
||||
|
||||
- `ConcordRegistry` holds `communities: Vec<Entity<Community>>`, an index by
|
||||
`CommunityId`, and `tasks: SmallVec<[Task<Result<(), Error>>; 2]>`.
|
||||
- `CommunityRegistry` holds `communities: Vec<Entity<Community>>`, an index by
|
||||
`CommunityId`, and `tasks: SmallVec<[Task<Result<()>>; 2]>`.
|
||||
- `Community` owns one `CommunityState`, the last `ControlFold`, the member list
|
||||
and the channel list. Views render `Entity<Community>`; no protocol state
|
||||
lives in a view.
|
||||
@@ -468,7 +469,7 @@ A background task never touches an entity. It sends results through a bounded
|
||||
|
||||
```rust
|
||||
let (signal_tx, signal_rx) = flume::bounded::<Signal>(256);
|
||||
let database = client.database().clone();
|
||||
let client = client.clone();
|
||||
|
||||
// Background: open, verify, fold — no entities.
|
||||
self.ingress = Some(cx.background_spawn(async move {
|
||||
@@ -477,7 +478,7 @@ self.ingress = Some(cx.background_spawn(async move {
|
||||
continue;
|
||||
};
|
||||
let (opened, rumor) = cord03::open(wrap, &plane.group, &plane.channel, plane.epoch)?;
|
||||
store::cache_rumor(database.as_ref(), &plane.channel, &opened).await?;
|
||||
store::cache_rumor(&client, &plane.channel, &opened).await?;
|
||||
signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -492,8 +493,8 @@ self.consumer = Some(cx.spawn(async move |this, cx| {
|
||||
}));
|
||||
```
|
||||
|
||||
- `client.database()` is a `&Arc<dyn NostrDatabase>` and `store::save_state`
|
||||
wants `&dyn NostrDatabase`, so clone the `Arc` and pass `database.as_ref()`.
|
||||
- Every store function takes the `&Client` and reaches the database through
|
||||
`client.database()`, so clone the `Client` into the background task.
|
||||
- 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
|
||||
the listener and the consumer.
|
||||
@@ -537,11 +538,13 @@ client.subscribe(filter).with_id(sub_id).await?;
|
||||
|
||||
## Not wired up yet
|
||||
|
||||
- **No registry and no sync engine.** `crates/concord` has no subscriptions, no
|
||||
`init`, and no `Entity<Community>`; the UI owns subscribing, routing a wrap to
|
||||
the plane whose address it carries, and rebuilding a subscription when a plane's
|
||||
address changes (join, channel added, rekey folded). GPUI integration above is
|
||||
the shape to build, not code that exists.
|
||||
- **`crates/concord` stays protocol-only; the registry lives in
|
||||
`crates/community`.** `concord` has no subscriptions, no `init`, and no
|
||||
`Entity<Community>`; `community::CommunityRegistry` owns one `Entity<Community>`
|
||||
per state document, subscribes when a community's plane set changes, and
|
||||
re-folds on an inbound wrap. Nothing observes `CommunityEvent` yet, and
|
||||
`CommunityRegistry::create` persists the genesis locally without publishing it
|
||||
to the metadata's relays.
|
||||
- **Account-key writers take any signer, not `&Keys`.** `genesis`,
|
||||
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
|
||||
the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,
|
||||
|
||||
Reference in New Issue
Block a user