update
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user