add guestbook and moderation

This commit is contained in:
2026-09-17 07:30:38 +07:00
parent d1b83fdc33
commit ea9abae554
7 changed files with 1196 additions and 58 deletions
+106 -13
View File
@@ -6,7 +6,9 @@ use anyhow::Result;
use nostr_sdk::prelude::*;
use crate::derive::channel_group_key;
use crate::edition::canonical_decimal;
use crate::edition::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
};
use crate::stream::{
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms,
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict,
@@ -96,6 +98,7 @@ pub enum ChatAction {
Delete {
target: EventId,
target_kind: Option<u16>,
citation: Option<AuthorityCitation>,
},
Typing,
Opaque,
@@ -217,6 +220,7 @@ pub fn build_delete(
epoch: Epoch,
target: EventId,
target_kind: Option<u16>,
citation: Option<&AuthorityCitation>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
@@ -226,6 +230,10 @@ pub fn build_delete(
tags.push(Tag::custom(TAG_TARGET_KIND, [target_kind.to_string()]));
}
if let Some(citation) = citation {
tags.push(citation_tag(citation));
}
build_rumor_ms(KIND_DELETE, author, "", tags, at_ms)
}
@@ -317,10 +325,10 @@ pub fn plane_keys(
.collect()
}
/// Folds the chat plane into timeline rows, newest first. A delete is honored
/// only from the message's own author, and a deletion is terminal: an edit or a
/// reaction arriving later never revives it.
pub fn fold(rumors: &[ChatRumor]) -> Vec<ChatMessage> {
pub fn fold(
rumors: &[ChatRumor],
can_delete: impl Fn(&PublicKey, Option<&AuthorityCitation>, &PublicKey) -> bool,
) -> Vec<ChatMessage> {
let mut order: Vec<usize> = (0..rumors.len()).collect();
order.sort_by_key(|&index| (rumors[index].at_ms, rumors[index].id));
@@ -356,8 +364,6 @@ pub fn fold(rumors: &[ChatRumor]) -> Vec<ChatMessage> {
});
}
// Mutations replay so the last one applied is the winner: the highest
// `at_ms` and, between equal ones, the lower inner rumor id.
let mut mutations: Vec<usize> = (0..rumors.len()).collect();
mutations.sort_by_key(|&index| (rumors[index].at_ms, Reverse(rumors[index].id)));
@@ -378,12 +384,16 @@ pub fn fold(rumors: &[ChatRumor]) -> Vec<ChatMessage> {
message.content = content.clone();
message.edited_at = Some(rumor.at_ms);
}
ChatAction::Delete { target, .. } => {
ChatAction::Delete {
target, citation, ..
} => {
let Some(&slot) = slot.get(target) else {
continue;
};
if messages[slot].author == rumor.author {
let author = messages[slot].author;
if author == rumor.author || can_delete(&rumor.author, citation.as_ref(), &author) {
messages[slot].deleted = true;
}
}
@@ -454,6 +464,7 @@ fn action_of(rumor: &UnsignedEvent) -> Result<ChatAction, ChatError> {
KIND_DELETE => Ok(ChatAction::Delete {
target: required_id(rumor, TAG_TARGET)?,
target_kind: optional_kind(rumor, TAG_TARGET_KIND)?,
citation: optional_citation(rumor)?,
}),
KIND_TYPING => Ok(ChatAction::Typing),
KIND_WEBXDC => Ok(ChatAction::Opaque),
@@ -469,8 +480,7 @@ fn optional_reply(
return Ok(None);
};
// NIP-C7 `q` and NIP-22 `E`/`e` put a relay hint at index 2 and the
// referenced author at index 3, which is a SHOULD, so absent reads as unknown.
// NIP-C7 `q` and NIP-22 `E`/`e` put a relay hint at index 2 and the referenced author at index 3.
let author = match fields.get(3).map(String::as_str) {
Some(hex) if !hex.is_empty() => Some(pubkey(hex, name)?),
_ => None,
@@ -500,6 +510,16 @@ 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))
}
fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
return Ok(None);
@@ -646,6 +666,7 @@ mod tests {
Epoch(0),
id,
Some(KIND_MESSAGE),
None,
AT + 3_000,
),
&group,
@@ -654,7 +675,7 @@ mod tests {
),
];
let folded = fold(&rumors);
let folded = fold(&rumors, |_, _, _| false);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].id, id);
@@ -698,6 +719,7 @@ mod tests {
Epoch(0),
id,
Some(KIND_MESSAGE),
None,
AT + 2_000,
),
&group,
@@ -706,7 +728,7 @@ mod tests {
),
];
let folded = fold(&rumors);
let folded = fold(&rumors, |_, _, _| false);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].content, "hello");
@@ -851,4 +873,75 @@ mod tests {
Err(ChatError::DuplicateTag(TAG_TARGET))
));
}
#[test]
fn a_moderator_delete_needs_the_roster_and_a_citation() {
let alice = Keys::generate();
let moderator = Keys::generate();
let peer = Keys::generate();
let group = group();
let message = read(
&build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT),
&group,
&alice,
Epoch(0),
);
let id = message.id;
let citation = AuthorityCitation {
entity: [0x33; 32],
version: 1,
hash: [0x44; 32],
};
let delete = |author: &Keys, citation: Option<&AuthorityCitation>| {
read(
&build_delete(
author.public_key(),
&channel(),
Epoch(0),
id,
Some(KIND_MESSAGE),
citation,
AT + 1_000,
),
&group,
author,
Epoch(0),
)
};
let can_delete =
|actor: &PublicKey, citation: Option<&AuthorityCitation>, author: &PublicKey| {
actor != author && citation.is_some() && actor == &moderator.public_key()
};
let cited = vec![message.clone(), delete(&moderator, Some(&citation))];
assert!(matches!(
&cited[1].action,
ChatAction::Delete { citation: Some(parsed), .. } if *parsed == citation
));
assert!(
fold(&cited, can_delete)[0].deleted,
"a cited moderator delete lands"
);
let uncited = vec![message.clone(), delete(&moderator, None)];
assert!(
!fold(&uncited, can_delete)[0].deleted,
"an uncited delete names no rank"
);
let peer_delete = vec![message.clone(), delete(&peer, Some(&citation))];
assert!(
!fold(&peer_delete, can_delete)[0].deleted,
"a peer's delete is not authority"
);
let own = vec![message.clone(), delete(&alice, None)];
assert!(
fold(&own, |_, _, _| false)[0].deleted,
"a self-delete never consults the predicate"
);
}
}