update
This commit is contained in:
Generated
+1
@@ -1318,6 +1318,7 @@ dependencies = [
|
|||||||
"data-encoding",
|
"data-encoding",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"hmac 0.12.1",
|
"hmac 0.12.1",
|
||||||
|
"log",
|
||||||
"nostr",
|
"nostr",
|
||||||
"nostr-memory",
|
"nostr-memory",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
|
|||||||
@@ -107,8 +107,9 @@ impl CommunityRegistry {
|
|||||||
/// Create a community owned by the current account and begin tracking it.
|
/// Create a community owned by the current account and begin tracking it.
|
||||||
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
|
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
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(
|
cx.emit(CommunityEvent::Error(
|
||||||
"cannot create a community without an account".to_owned(),
|
"cannot create a community without an account".to_owned(),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ use concord::{ChannelId, CommunityId, Epoch, GroupKey};
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::UniversalSigner;
|
use state::UniversalSigner;
|
||||||
|
|
||||||
const SUBSCRIPTION_PREFIX: &str = "concord/";
|
|
||||||
const STATE_PREFIX: &str = "concord/";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum PlaneKind {
|
pub enum PlaneKind {
|
||||||
Control(Epoch),
|
Control(Epoch),
|
||||||
@@ -77,13 +74,13 @@ pub fn subscription_filter(planes: &[Plane]) -> Filter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn subscription_id(id: &CommunityId) -> SubscriptionId {
|
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> {
|
pub fn community_of(subscription_id: &SubscriptionId) -> Option<CommunityId> {
|
||||||
subscription_id
|
subscription_id
|
||||||
.as_str()
|
.as_str()
|
||||||
.strip_prefix(SUBSCRIPTION_PREFIX)?
|
.strip_prefix(store::STATE_PREFIX)?
|
||||||
.parse()
|
.parse()
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
@@ -130,30 +127,7 @@ pub async fn load(
|
|||||||
signer: &UniversalSigner,
|
signer: &UniversalSigner,
|
||||||
self_pk: PublicKey,
|
self_pk: PublicKey,
|
||||||
) -> Result<Vec<CommunityState>> {
|
) -> Result<Vec<CommunityState>> {
|
||||||
let filter = Filter::new().kind(Kind::ApplicationSpecificData);
|
let mut states = store::load_states(client).await?;
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(list) = load_list(client, signer, self_pk).await? {
|
if let Some(list) = load_list(client, signer, self_pk).await? {
|
||||||
states.retain(|state| list.is_live(&state.id));
|
states.retain(|state| list.is_live(&state.id));
|
||||||
@@ -162,12 +136,6 @@ pub async fn load(
|
|||||||
Ok(states)
|
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(
|
async fn load_list(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
signer: &UniversalSigner,
|
signer: &UniversalSigner,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ rand.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
nostr-memory.workspace = true
|
nostr-memory.workspace = true
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use data_encoding::HEXLOWER;
|
use data_encoding::HEXLOWER;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
use crate::cord01::{
|
use crate::cord01::{
|
||||||
KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap,
|
KIND_WRAP, OpenedStream, SealForm, build_rumor_ms, build_seal, open_wrap, wrap_seal,
|
||||||
wrap_seal,
|
|
||||||
};
|
|
||||||
use crate::cord04::{
|
|
||||||
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
|
|
||||||
};
|
};
|
||||||
|
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};
|
use crate::{GroupKey, decode_hex_32};
|
||||||
|
|
||||||
pub const KIND_JOIN_LEAVE: u16 = 3306;
|
pub const KIND_JOIN_LEAVE: u16 = 3306;
|
||||||
@@ -29,41 +27,6 @@ const TAG_CONTENT: &str = "content";
|
|||||||
const CONTENT_JOIN: &str = "join";
|
const CONTENT_JOIN: &str = "join";
|
||||||
const CONTENT_LEAVE: &str = "leave";
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum GuestbookEntry {
|
pub enum GuestbookEntry {
|
||||||
Join {
|
Join {
|
||||||
@@ -469,73 +432,21 @@ fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), Guestboo
|
|||||||
Ok((snapshot_id, (index, total)))
|
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> {
|
fn decimal(raw: &str) -> Result<u32, GuestbookError> {
|
||||||
canonical_decimal(raw)
|
canonical_decimal(raw)
|
||||||
.and_then(|value| u32::try_from(value).ok())
|
.and_then(|value| u32::try_from(value).ok())
|
||||||
.ok_or(GuestbookError::BadTag(TAG_SNAP))
|
.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> {
|
fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
|
||||||
pubkey(value(required(rumor, name)?, name)?, name)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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::derive::guestbook_group_key;
|
||||||
use crate::{CommunityId, Epoch};
|
use crate::{CommunityId, Epoch};
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
use crate::cord01::{
|
use crate::cord01::{
|
||||||
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms,
|
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, build_rumor_ms, build_seal,
|
||||||
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict,
|
channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, wrap_seal,
|
||||||
wrap_seal,
|
|
||||||
};
|
|
||||||
use crate::cord04::{
|
|
||||||
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
|
|
||||||
};
|
};
|
||||||
|
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::derive::channel_group_key;
|
||||||
use crate::{ChannelId, Epoch, GroupKey, decode_hex_32};
|
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_EXPIRATION: &str = "expiration";
|
||||||
const TAG_TIMER: &str = "timer";
|
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.
|
/// A chat event another chat event refers to: a quote, a comment's parent, a reaction's target.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct ReplyRef {
|
pub struct ReplyRef {
|
||||||
@@ -579,16 +541,6 @@ fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<u16
|
|||||||
.map_err(|_| ChatError::BadTag(name))
|
.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> {
|
pub fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
|
||||||
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
|
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
|
||||||
return Ok(None);
|
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> {
|
fn hex_id(fields: &[String], name: &'static str) -> Result<EventId, ChatError> {
|
||||||
let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?;
|
let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?;
|
||||||
|
|
||||||
EventId::from_slice(&bytes).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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::cord01::StreamError;
|
||||||
|
|
||||||
const SECRET: [u8; 32] = [0x2du8; 32];
|
const SECRET: [u8; 32] = [0x2du8; 32];
|
||||||
const AT: u64 = 1_700_000_000_417;
|
const AT: u64 = 1_700_000_000_417;
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ fn signing_bytes(
|
|||||||
bytes
|
bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn edition_hash(
|
fn edition_hash(
|
||||||
entity: &[u8; 32],
|
entity: &[u8; 32],
|
||||||
version: u64,
|
version: u64,
|
||||||
prev: Option<&[u8; 32]>,
|
prev: Option<&[u8; 32]>,
|
||||||
@@ -246,14 +246,14 @@ impl From<&ParsedEdition> for EditionMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub struct FoldResult {
|
struct FoldResult {
|
||||||
pub head: Option<usize>,
|
pub head: Option<usize>,
|
||||||
pub gap: bool,
|
pub gap: bool,
|
||||||
pub anchored: bool,
|
pub anchored: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The highest version whose chain is intact, given a held floor.
|
/// 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();
|
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
|
||||||
|
|
||||||
for (index, edition) in editions.iter().enumerate() {
|
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.
|
/// The highest version overall, ignoring contiguity.
|
||||||
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
||||||
editions
|
editions
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ pub struct Role {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Role {
|
impl Role {
|
||||||
pub fn parse(content: &str) -> Option<Self> {
|
fn parse(content: &str) -> Option<Self> {
|
||||||
serde_json::from_str(content).ok()
|
serde_json::from_str(content).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ pub struct Grant {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Grant {
|
impl Grant {
|
||||||
pub fn parse(content: &str) -> Option<Self> {
|
fn parse(content: &str) -> Option<Self> {
|
||||||
serde_json::from_str(content).ok()
|
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 entries: Vec<String> = serde_json::from_str(content).ok()?;
|
||||||
let mut banned = Vec::with_capacity(entries.len());
|
let mut banned = Vec::with_capacity(entries.len());
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
mod rumor;
|
||||||
|
|
||||||
pub mod cord01;
|
pub mod cord01;
|
||||||
pub mod cord02;
|
pub mod cord02;
|
||||||
pub mod cord03;
|
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 MARK_VALUE: &str = "concord";
|
||||||
const WRAP_TAG: &str = "e";
|
const WRAP_TAG: &str = "e";
|
||||||
const KIND_TAG: &str = "k";
|
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.
|
/// An already-expired rumor is refused at ingest. Returns whether it was kept.
|
||||||
pub async fn cache_rumor(
|
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(
|
pub async fn query_rumors(
|
||||||
database: &dyn NostrDatabase,
|
client: &Client,
|
||||||
channel: &ChannelId,
|
channel: &ChannelId,
|
||||||
until: Option<Timestamp>,
|
until: Option<Timestamp>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
@@ -106,7 +107,7 @@ pub async fn query_rumors(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut newest: BTreeMap<String, Event> = BTreeMap::new();
|
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 {
|
let Some(rumor_id) = event.tags.identifier() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -297,21 +298,54 @@ pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
|
pub async fn load_state(client: &Client, id: &CommunityId) -> Result<Option<CommunityState>> {
|
||||||
where
|
|
||||||
D: NostrDatabase + ?Sized,
|
|
||||||
{
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.identifier(state_identifier(id))
|
.identifier(state_identifier(id))
|
||||||
.limit(1);
|
.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)?)),
|
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
|
||||||
None => Ok(None),
|
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(
|
pub async fn backfill(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
channel: &ChannelId,
|
channel: &ChannelId,
|
||||||
@@ -492,4 +526,43 @@ mod tests {
|
|||||||
["after the rekey", "still before", "before the rekey"]
|
["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] {
|
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 {
|
CommunityEntry {
|
||||||
name: "Coop Contributors",
|
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
|
the base64 record). `cargo clippy -p concord --all-targets` and
|
||||||
`cargo fmt -p concord --check` are clean.
|
`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
|
1. DONE — `store::load_states(client)` added (with a direct `store` test), the
|
||||||
(`crates/community/src/sync.rs:100-137`).
|
app-side state-document scan in `sync::load` is gone.
|
||||||
2. Export the `concord/` state prefix from concord; delete the app-side copies
|
2. DONE — `store::STATE_PREFIX` is public; the app-side `concord/` literals are
|
||||||
(`store.rs:26`, `sync.rs:15-16`).
|
gone, and subscription ids reuse the exported prefix.
|
||||||
3. Collapse the duplicated tag parsers (`cord03.rs:614-654` vs
|
3. DONE — the shared rumor tag readers and error live in a new `cords::rumor`
|
||||||
`guestbook.rs:496-530`) and the identical `ChatError`/`GuestbookError`
|
module (`RumorError`, `tag`, `required`, `value`, `pubkey`,
|
||||||
enums.
|
`optional_citation`), re-exported as `cord03::ChatError` and
|
||||||
4. Remove never-varied parameters where the change is local: `banned_at` from
|
`cord02::guestbook::GuestbookError`. `cord06` keeps its own narrower
|
||||||
`complete_memberlist` (doc admits "empty today"), `cache_rumor -> Result<()>`
|
`RekeyError`, which the plan scoped out.
|
||||||
once nothing reads the bool, `snapshot_authority`/`ephemeral`/`query_rumors
|
4. RETAINED — none of the "never-varied parameters" were removed. Each is
|
||||||
(until)` if no scheduled flow needs them.
|
load-bearing for a flow the fold or a writer already implements (D1):
|
||||||
5. Tighten visibility of internal-only `pub` items in `cord04`
|
- `complete_memberlist`'s `banned_at` is read by the fold and is exercised
|
||||||
(`edition_hash`, `fold`, `FoldResult`, `bootstrap_head`, `HeadSelection`,
|
with a non-empty map by `join_leave_kick_and_snapshot_converge_to_one_memberlist`;
|
||||||
`parse_banlist`, `Role::parse`, `Grant::parse`).
|
`docs/concord-usage.md` already promises to fill it once the banlist head's
|
||||||
6. Fix doc drift: `backfill` arity (`docs/concord-usage.md:212`), `save_state`
|
timestamp is plumbed through.
|
||||||
parameter (`:487`), `init` signature (`:431-432`), and refresh the "Not wired
|
- `cache_rumor -> Result<bool>` is read by `backfill` to drop expired rumors.
|
||||||
up yet" section (`:528-545`) once Phase 2 lands.
|
- `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 |
|
| `cord04::pins` | ~550 | none |
|
||||||
| `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` |
|
| `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` |
|
||||||
| guestbook / list write paths | ~240 | `open`, `coalesce`, `complete_memberlist`, `is_live` |
|
| 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):
|
Truly unreferenced even by tests (safe candidates, but kept per D1):
|
||||||
`CommunityInvite::expired`, `GroupKey::pk_hex`, `From<[u8; 32]>` impls,
|
`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
|
- Phase 2 adds the app-level test: seed a `CommunityState` via
|
||||||
`store::save_state`, drive `CommunityRegistry`, assert a subscription is made
|
`store::save_state`, drive `CommunityRegistry`, assert a subscription is made
|
||||||
and an inbound wrap folds into the community.
|
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
|
## 6. Immediate unblock
|
||||||
|
|
||||||
|
|||||||
+25
-22
@@ -73,7 +73,7 @@ let editions: Vec<ParsedEdition> = minted
|
|||||||
.collect::<Result<_, _>>()?;
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?;
|
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
|
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 {
|
let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
store::cache_rumor(database, &channel, &opened).await?;
|
store::cache_rumor(&client, &channel, &opened).await?;
|
||||||
rumors.push(rumor);
|
rumors.push(rumor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,13 +209,13 @@ 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, database, &channel, &held, until, 50).await?;
|
let page = store::backfill(client, &channel, &held, until, 50).await?;
|
||||||
let cached = store::query_rumors(database, &channel, None, 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
|
`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
|
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
|
any other local sweep — the timer is cooperative, so the local store is the
|
||||||
artifact that has to forget.
|
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 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 content = cord04::pins::publishable(&read, channel_is_private, &plane, epoch)?;
|
||||||
let (wrap, _) = writer.set_pin_list(
|
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
|
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
|
## 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
|
entity per community, and moves every decrypt, verification, fold and I/O off
|
||||||
the foreground thread.
|
the foreground thread.
|
||||||
|
|
||||||
@@ -437,13 +438,13 @@ the foreground thread.
|
|||||||
Same shape as `ChatRegistry`:
|
Same shape as `ChatRegistry`:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub fn init(window: &mut Window, cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
ConcordRegistry::set_global(cx.new(|cx| ConcordRegistry::new(window, cx)), cx);
|
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConcordRegistry {
|
impl CommunityRegistry {
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
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
|
subscribe to `NostrRegistry` for `SignerChanged` so the communities reset with
|
||||||
the account.
|
the account.
|
||||||
|
|
||||||
- `ConcordRegistry` holds `communities: Vec<Entity<Community>>`, an index by
|
- `CommunityRegistry` holds `communities: Vec<Entity<Community>>`, an index by
|
||||||
`CommunityId`, and `tasks: SmallVec<[Task<Result<(), Error>>; 2]>`.
|
`CommunityId`, and `tasks: SmallVec<[Task<Result<()>>; 2]>`.
|
||||||
- `Community` owns one `CommunityState`, the last `ControlFold`, the member list
|
- `Community` owns one `CommunityState`, the last `ControlFold`, the member list
|
||||||
and the channel list. Views render `Entity<Community>`; no protocol state
|
and the channel list. Views render `Entity<Community>`; no protocol state
|
||||||
lives in a view.
|
lives in a view.
|
||||||
@@ -468,7 +469,7 @@ A background task never touches an entity. It sends results through a bounded
|
|||||||
|
|
||||||
```rust
|
```rust
|
||||||
let (signal_tx, signal_rx) = flume::bounded::<Signal>(256);
|
let (signal_tx, signal_rx) = flume::bounded::<Signal>(256);
|
||||||
let database = client.database().clone();
|
let client = client.clone();
|
||||||
|
|
||||||
// Background: open, verify, fold — no entities.
|
// Background: open, verify, fold — no entities.
|
||||||
self.ingress = Some(cx.background_spawn(async move {
|
self.ingress = Some(cx.background_spawn(async move {
|
||||||
@@ -477,7 +478,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(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?;
|
signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
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`
|
- Every store function takes the `&Client` and reaches the database through
|
||||||
wants `&dyn NostrDatabase`, so clone the `Arc` and pass `database.as_ref()`.
|
`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
|
||||||
the listener and the consumer.
|
the listener and the consumer.
|
||||||
@@ -537,11 +538,13 @@ client.subscribe(filter).with_id(sub_id).await?;
|
|||||||
|
|
||||||
## Not wired up yet
|
## Not wired up yet
|
||||||
|
|
||||||
- **No registry and no sync engine.** `crates/concord` has no subscriptions, no
|
- **`crates/concord` stays protocol-only; the registry lives in
|
||||||
`init`, and no `Entity<Community>`; the UI owns subscribing, routing a wrap to
|
`crates/community`.** `concord` has no subscriptions, no `init`, and no
|
||||||
the plane whose address it carries, and rebuilding a subscription when a plane's
|
`Entity<Community>`; `community::CommunityRegistry` owns one `Entity<Community>`
|
||||||
address changes (join, channel added, rekey folded). GPUI integration above is
|
per state document, subscribes when a community's plane set changes, and
|
||||||
the shape to build, not code that exists.
|
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`,
|
- **Account-key writers take any signer, not `&Keys`.** `genesis`,
|
||||||
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
|
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
|
||||||
the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,
|
the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,
|
||||||
|
|||||||
Reference in New Issue
Block a user