mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add relay support for NIP-51 lists (#616)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
use nostr::{EventBuilder, Kind, Tag};
|
||||
use serde::Deserialize;
|
||||
use sprout_sdk::kind::{
|
||||
KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_FOLLOW_SET, KIND_MUTE_LIST,
|
||||
KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST,
|
||||
};
|
||||
|
||||
use crate::client::SproutClient;
|
||||
use crate::error::CliError;
|
||||
@@ -112,6 +117,90 @@ pub async fn cmd_get_contact_list(client: &SproutClient, pubkey: &str) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_social_list_kind(kind: u32) -> Result<(), CliError> {
|
||||
match kind {
|
||||
KIND_MUTE_LIST
|
||||
| KIND_PIN_LIST
|
||||
| KIND_NIP65_RELAY_LIST_METADATA
|
||||
| KIND_BOOKMARK_LIST
|
||||
| KIND_FOLLOW_SET
|
||||
| KIND_BOOKMARK_SET => Ok(()),
|
||||
_ => Err(CliError::Usage(format!(
|
||||
"unsupported social list kind {kind}; supported kinds: 10000, 10001, 10002, 10003, 30000, 30003"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_parameterized_social_list_kind(kind: u32) -> bool {
|
||||
matches!(kind, KIND_FOLLOW_SET | KIND_BOOKMARK_SET)
|
||||
}
|
||||
|
||||
fn parse_tags_json(tags_json: &str) -> Result<Vec<Tag>, CliError> {
|
||||
let raw_tags: Vec<Vec<String>> = serde_json::from_str(tags_json)
|
||||
.map_err(|e| CliError::Usage(format!("invalid tags JSON: {e}")))?;
|
||||
raw_tags
|
||||
.iter()
|
||||
.map(|parts| {
|
||||
let refs: Vec<&str> = parts.iter().map(String::as_str).collect();
|
||||
Tag::parse(&refs).map_err(|e| CliError::Usage(format!("invalid tag {parts:?}: {e}")))
|
||||
})
|
||||
.collect::<Result<_, _>>()
|
||||
}
|
||||
|
||||
fn has_d_tag(tags: &[Tag]) -> bool {
|
||||
tags.iter()
|
||||
.any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("d"))
|
||||
}
|
||||
|
||||
pub async fn cmd_set_list(
|
||||
client: &SproutClient,
|
||||
kind: u16,
|
||||
tags_json: &str,
|
||||
content: &str,
|
||||
) -> Result<(), CliError> {
|
||||
let kind_u32 = u32::from(kind);
|
||||
validate_social_list_kind(kind_u32)?;
|
||||
let tags = parse_tags_json(tags_json)?;
|
||||
if is_parameterized_social_list_kind(kind_u32) && !has_d_tag(&tags) {
|
||||
return Err(CliError::Usage(format!(
|
||||
"kind {kind} is parameterized replaceable and requires a d tag"
|
||||
)));
|
||||
}
|
||||
|
||||
let builder = EventBuilder::new(Kind::Custom(kind), content, tags);
|
||||
let event = client.sign_event(builder)?;
|
||||
let resp = client.submit_event(event).await?;
|
||||
println!("{resp}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_get_list(
|
||||
client: &SproutClient,
|
||||
pubkey: &str,
|
||||
kind: u32,
|
||||
d_tag: Option<&str>,
|
||||
) -> Result<(), CliError> {
|
||||
validate_hex64(pubkey)?;
|
||||
validate_social_list_kind(kind)?;
|
||||
if !is_parameterized_social_list_kind(kind) && d_tag.is_some() {
|
||||
return Err(CliError::Usage(format!(
|
||||
"kind {kind} is not parameterized; omit --d-tag"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut filter = serde_json::json!({
|
||||
"kinds": [kind],
|
||||
"authors": [pubkey],
|
||||
"limit": 10
|
||||
});
|
||||
if let Some(d) = d_tag {
|
||||
filter["#d"] = serde_json::json!([d]);
|
||||
}
|
||||
let resp = client.query(&filter).await?;
|
||||
println!("{resp}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -130,5 +219,62 @@ pub async fn dispatch(cmd: crate::SocialCmd, client: &SproutClient) -> Result<()
|
||||
before,
|
||||
} => cmd_get_user_notes(client, &pubkey, limit, before).await,
|
||||
SocialCmd::GetContactList { pubkey } => cmd_get_contact_list(client, &pubkey).await,
|
||||
SocialCmd::SetList {
|
||||
kind,
|
||||
tags,
|
||||
content,
|
||||
} => cmd_set_list(client, kind, &tags, &content).await,
|
||||
SocialCmd::GetList {
|
||||
pubkey,
|
||||
kind,
|
||||
d_tag,
|
||||
} => cmd_get_list(client, &pubkey, kind, d_tag.as_deref()).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn social_list_kind_validation_accepts_supported_kinds() {
|
||||
for kind in [
|
||||
KIND_MUTE_LIST,
|
||||
KIND_PIN_LIST,
|
||||
KIND_NIP65_RELAY_LIST_METADATA,
|
||||
KIND_BOOKMARK_LIST,
|
||||
KIND_FOLLOW_SET,
|
||||
KIND_BOOKMARK_SET,
|
||||
] {
|
||||
assert!(validate_social_list_kind(kind).is_ok(), "kind {kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn social_list_kind_validation_rejects_unsupported_kinds() {
|
||||
let err = validate_social_list_kind(30002).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, CliError::Usage(msg) if msg.contains("unsupported social list kind 30002"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_tags_json_and_detects_d_tag() {
|
||||
let tags = parse_tags_json(r#"[["d","friends"],["p","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]]"#)
|
||||
.expect("tags parse");
|
||||
assert!(has_d_tag(&tags));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_tags_json_is_usage_error() {
|
||||
let err = parse_tags_json("not json").unwrap_err();
|
||||
assert!(matches!(err, CliError::Usage(msg) if msg.contains("invalid tags JSON")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameterized_social_list_kind_detection() {
|
||||
assert!(is_parameterized_social_list_kind(KIND_FOLLOW_SET));
|
||||
assert!(is_parameterized_social_list_kind(KIND_BOOKMARK_SET));
|
||||
assert!(!is_parameterized_social_list_kind(KIND_MUTE_LIST));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -746,6 +746,32 @@ pub enum SocialCmd {
|
||||
#[arg(long)]
|
||||
pubkey: String,
|
||||
},
|
||||
/// Publish a NIP-51/NIP-65 social list or set.
|
||||
#[command(name = "set-list")]
|
||||
SetList {
|
||||
/// Supported kind: 10000, 10001, 10002, 10003, 30000, or 30003.
|
||||
#[arg(long)]
|
||||
kind: u16,
|
||||
/// JSON array of Nostr tags, e.g. [["p","<hex>"],["d","friends"]].
|
||||
#[arg(long)]
|
||||
tags: String,
|
||||
/// Event content.
|
||||
#[arg(long, default_value = "")]
|
||||
content: String,
|
||||
},
|
||||
/// Get NIP-51/NIP-65 social lists or sets by author and kind.
|
||||
#[command(name = "list")]
|
||||
GetList {
|
||||
/// 64-char hex pubkey of the author.
|
||||
#[arg(long)]
|
||||
pubkey: String,
|
||||
/// Supported kind: 10000, 10001, 10002, 10003, 30000, or 30003.
|
||||
#[arg(long)]
|
||||
kind: u32,
|
||||
/// Optional d-tag for parameterized replaceable sets.
|
||||
#[arg(long)]
|
||||
d_tag: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1041,7 +1067,15 @@ mod tests {
|
||||
assert_eq!(names(&cmd, "feed"), vec!["get"]);
|
||||
assert_eq!(
|
||||
names(&cmd, "social"),
|
||||
vec!["contacts", "event", "notes", "publish", "set-contacts"]
|
||||
vec![
|
||||
"contacts",
|
||||
"event",
|
||||
"list",
|
||||
"notes",
|
||||
"publish",
|
||||
"set-contacts",
|
||||
"set-list"
|
||||
]
|
||||
);
|
||||
assert_eq!(names(&cmd, "repos"), vec!["create", "get", "list"]);
|
||||
assert_eq!(names(&cmd, "upload"), vec!["file"]);
|
||||
@@ -1059,7 +1093,7 @@ mod tests {
|
||||
("pack", 2),
|
||||
("reactions", 3),
|
||||
("repos", 3),
|
||||
("social", 5),
|
||||
("social", 7),
|
||||
("upload", 1),
|
||||
("users", 4),
|
||||
("workflows", 8),
|
||||
|
||||
@@ -11,6 +11,34 @@ pub const KIND_PROFILE: u32 = 0;
|
||||
pub const KIND_TEXT_NOTE: u32 = 1;
|
||||
/// NIP-02: Contact list / follow list.
|
||||
pub const KIND_CONTACT_LIST: u32 = 3;
|
||||
/// NIP-51: Mute list (replaceable, 10000–19999 range) — pubkeys/events/threads/words a user has muted.
|
||||
///
|
||||
/// User-owned global state, keyed by `(pubkey, kind)`. Same ownership/scope shape as kind:3.
|
||||
pub const KIND_MUTE_LIST: u32 = 10000;
|
||||
/// NIP-51: Pin list (replaceable) — events the user has pinned to their profile.
|
||||
///
|
||||
/// User-owned global state, keyed by `(pubkey, kind)`. The events referenced may live in
|
||||
/// channels, but the pin list itself is profile-level state.
|
||||
pub const KIND_PIN_LIST: u32 = 10001;
|
||||
/// NIP-65: Relay list metadata (replaceable) — read/write relay preferences for the outbox model.
|
||||
///
|
||||
/// User-owned global state, keyed by `(pubkey, kind)`. Tags are `["r", url]` or
|
||||
/// `["r", url, "read"]` / `["r", url, "write"]`.
|
||||
pub const KIND_NIP65_RELAY_LIST_METADATA: u32 = 10002;
|
||||
/// NIP-51: Bookmark list (replaceable) — events/articles/hashtags/URLs the user has bookmarked.
|
||||
///
|
||||
/// User-owned global state, keyed by `(pubkey, kind)`. References content but is not itself
|
||||
/// channel-scoped content.
|
||||
pub const KIND_BOOKMARK_LIST: u32 = 10003;
|
||||
/// NIP-51: Follow set (parameterized replaceable, 30000–39999 range) — named curated lists of pubkeys.
|
||||
///
|
||||
/// User-owned, keyed by `(pubkey, kind, d_tag)`. Allows multiple named follow lists on top of
|
||||
/// the single kind:3 contact list (e.g. "close-friends", "news", "devs").
|
||||
pub const KIND_FOLLOW_SET: u32 = 30000;
|
||||
/// NIP-51: Bookmark set (parameterized replaceable) — named curated bookmark collections.
|
||||
///
|
||||
/// User-owned, keyed by `(pubkey, kind, d_tag)`.
|
||||
pub const KIND_BOOKMARK_SET: u32 = 30003;
|
||||
/// NIP-01: Channel metadata (replaceable). Not used by Sprout today.
|
||||
pub const KIND_CHANNEL_METADATA: u32 = 41;
|
||||
/// NIP-09: Event deletion request.
|
||||
@@ -322,6 +350,12 @@ pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_PROFILE,
|
||||
KIND_TEXT_NOTE,
|
||||
KIND_CONTACT_LIST,
|
||||
KIND_MUTE_LIST,
|
||||
KIND_PIN_LIST,
|
||||
KIND_NIP65_RELAY_LIST_METADATA,
|
||||
KIND_BOOKMARK_LIST,
|
||||
KIND_FOLLOW_SET,
|
||||
KIND_BOOKMARK_SET,
|
||||
KIND_CHANNEL_METADATA,
|
||||
KIND_DELETION,
|
||||
KIND_REACTION,
|
||||
|
||||
@@ -13,23 +13,24 @@ use nostr::Event;
|
||||
use sprout_auth::Scope;
|
||||
use sprout_core::kind::{
|
||||
event_kind_u32, is_parameterized_replaceable, is_relay_admin_kind, KIND_AGENT_ENGRAM,
|
||||
KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_CANVAS, KIND_CONTACT_LIST,
|
||||
KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, KIND_FORUM_COMMENT,
|
||||
KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH,
|
||||
KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE,
|
||||
KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN,
|
||||
KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED,
|
||||
KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED,
|
||||
KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION,
|
||||
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT,
|
||||
KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST,
|
||||
KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER,
|
||||
KIND_NIP43_LEAVE_REQUEST, KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE,
|
||||
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
|
||||
KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
|
||||
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS,
|
||||
KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE,
|
||||
RELAY_ADMIN_REMOVE_MEMBER,
|
||||
KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET,
|
||||
KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN,
|
||||
KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP,
|
||||
KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST,
|
||||
KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT,
|
||||
KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES,
|
||||
KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE,
|
||||
KIND_HUDDLE_STARTED, KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM,
|
||||
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MUTE_LIST,
|
||||
KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP,
|
||||
KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
|
||||
KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST,
|
||||
KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PROFILE,
|
||||
KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED,
|
||||
KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED,
|
||||
KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE,
|
||||
KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER,
|
||||
RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
|
||||
};
|
||||
use sprout_core::verification::verify_event;
|
||||
|
||||
@@ -153,6 +154,14 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
|
||||
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM => {
|
||||
Ok(Scope::UsersWrite)
|
||||
}
|
||||
// NIP-51 standard lists and NIP-65 relay list — user-owned global state,
|
||||
// same ownership shape as kind:3 (contacts) and kind:0 (profile).
|
||||
KIND_MUTE_LIST
|
||||
| KIND_PIN_LIST
|
||||
| KIND_NIP65_RELAY_LIST_METADATA
|
||||
| KIND_BOOKMARK_LIST
|
||||
| KIND_FOLLOW_SET
|
||||
| KIND_BOOKMARK_SET => Ok(Scope::UsersWrite),
|
||||
KIND_DELETION
|
||||
| KIND_REACTION
|
||||
| KIND_GIFT_WRAP
|
||||
@@ -302,6 +311,15 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
|
||||
| KIND_LONG_FORM
|
||||
| KIND_USER_STATUS
|
||||
| KIND_READ_STATE
|
||||
// NIP-51 standard lists + sets and NIP-65 relay list — user-owned global state.
|
||||
// Same as kind:3 (contacts): keyed by (pubkey, kind) or (pubkey, kind, d_tag),
|
||||
// never channel-scoped. A stray `h` tag must not channel-scope them.
|
||||
| KIND_MUTE_LIST
|
||||
| KIND_PIN_LIST
|
||||
| KIND_NIP65_RELAY_LIST_METADATA
|
||||
| KIND_BOOKMARK_LIST
|
||||
| KIND_FOLLOW_SET
|
||||
| KIND_BOOKMARK_SET
|
||||
// NIP-AE agent engrams are addressed by (pubkey_a, kind, d_tag); never channel-scoped.
|
||||
| KIND_AGENT_ENGRAM
|
||||
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
|
||||
@@ -1840,6 +1858,13 @@ mod tests {
|
||||
KIND_FORUM_COMMENT,
|
||||
KIND_LONG_FORM,
|
||||
KIND_USER_STATUS,
|
||||
// NIP-51 lists + sets, NIP-65 relay list
|
||||
KIND_MUTE_LIST,
|
||||
KIND_PIN_LIST,
|
||||
KIND_NIP65_RELAY_LIST_METADATA,
|
||||
KIND_BOOKMARK_LIST,
|
||||
KIND_FOLLOW_SET,
|
||||
KIND_BOOKMARK_SET,
|
||||
KIND_AGENT_ENGRAM,
|
||||
];
|
||||
for kind in migrated {
|
||||
@@ -1850,6 +1875,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip51_and_nip65_lists_require_users_write() {
|
||||
let dummy = make_dummy_event();
|
||||
for kind in [
|
||||
KIND_MUTE_LIST,
|
||||
KIND_PIN_LIST,
|
||||
KIND_NIP65_RELAY_LIST_METADATA,
|
||||
KIND_BOOKMARK_LIST,
|
||||
KIND_FOLLOW_SET,
|
||||
KIND_BOOKMARK_SET,
|
||||
] {
|
||||
assert_eq!(
|
||||
required_scope_for_kind(kind, &dummy).ok(),
|
||||
Some(Scope::UsersWrite),
|
||||
"kind {kind} should require UsersWrite scope"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip51_and_nip65_lists_are_global_only() {
|
||||
for kind in [
|
||||
KIND_MUTE_LIST,
|
||||
KIND_PIN_LIST,
|
||||
KIND_NIP65_RELAY_LIST_METADATA,
|
||||
KIND_BOOKMARK_LIST,
|
||||
KIND_FOLLOW_SET,
|
||||
KIND_BOOKMARK_SET,
|
||||
] {
|
||||
assert!(
|
||||
is_global_only_kind(kind),
|
||||
"kind {kind} should be global-only (never channel-scoped)"
|
||||
);
|
||||
assert!(
|
||||
!requires_h_channel_scope(kind),
|
||||
"kind {kind} must not require an h-tag channel scope"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_kind_rejected() {
|
||||
let dummy = make_dummy_event();
|
||||
|
||||
Reference in New Issue
Block a user