add guestbook and moderation
This commit is contained in:
+106
-13
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user