Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions crates/sprout-cli/src/commands/emoji.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
use crate::client::{normalize_write_response, SproutClient};
use crate::error::CliError;
use sprout_sdk::CustomEmoji;

/// d-tag for a member's own custom emoji set (kind:30030). Mirrors the SDK
/// constant; the workspace palette is the union of every member's own set.
const CUSTOM_EMOJI_SET_D_TAG: &str = sprout_sdk::CUSTOM_EMOJI_SET_D_TAG;

/// Custom emoji entry in CLI output.
#[derive(Debug, serde::Serialize)]
struct EmojiEntry {
shortcode: String,
url: String,
}

/// Parse `["emoji", shortcode, url]` tags from one event into entries.
fn emoji_tags_of(event: &serde_json::Value) -> Vec<EmojiEntry> {
let Some(tags) = event.get("tags").and_then(|v| v.as_array()) else {
return vec![];
};
let mut out = Vec::new();
for tag in tags {
let Some(parts) = tag.as_array() else {
continue;
};
if parts.first().and_then(|v| v.as_str()) != Some("emoji") {
continue;
}
let (Some(shortcode), Some(url)) = (
parts.get(1).and_then(|v| v.as_str()),
parts.get(2).and_then(|v| v.as_str()),
) else {
continue;
};
out.push(EmojiEntry {
shortcode: shortcode.to_string(),
url: url.to_string(),
});
}
out
}

/// Union every member's kind:30030 set, deduped by `(shortcode, url)`.
/// Stable, sorted by shortcode then url, so identical input yields identical output.
fn union_custom_emoji(events: &[serde_json::Value]) -> Vec<EmojiEntry> {
let mut seen = std::collections::HashSet::new();
let mut out: Vec<EmojiEntry> = Vec::new();
for event in events {
for entry in emoji_tags_of(event) {
if seen.insert((entry.shortcode.clone(), entry.url.clone())) {
out.push(entry);
}
}
}
out.sort_by(|a, b| a.shortcode.cmp(&b.shortcode).then(a.url.cmp(&b.url)));
out
}

/// List the workspace custom emoji palette: the union of every member's
/// own kind:30030 set (d=`sprout:custom-emoji`).
async fn cmd_list(client: &SproutClient) -> Result<(), CliError> {
let filter = serde_json::json!({
"kinds": [sprout_sdk::kind::KIND_EMOJI_SET],
"#d": [CUSTOM_EMOJI_SET_D_TAG],
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse emoji set query: {e}")))?;
let emojis = union_custom_emoji(&events);
let output = serde_json::json!({ "emojis": emojis });
println!("{}", serde_json::to_string(&output).unwrap_or_default());
Ok(())
}

/// Fetch the caller's own current custom emoji set (latest kind:30030 under
/// the d-tag, authored by the caller). Empty when none published yet.
async fn fetch_own_emoji(client: &SproutClient) -> Result<Vec<CustomEmoji>, CliError> {
let me = client.keys().public_key().to_hex();
let filter = serde_json::json!({
"kinds": [sprout_sdk::kind::KIND_EMOJI_SET],
"#d": [CUSTOM_EMOJI_SET_D_TAG],
"authors": [me],
"limit": 1,
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse own emoji set: {e}")))?;
// The relay keeps only the latest per (pubkey, d_tag), but be defensive.
let Some(event) = events.last() else {
return Ok(vec![]);
};
Ok(emoji_tags_of(event)
.into_iter()
.map(|e| CustomEmoji {
shortcode: e.shortcode,
url: e.url,
})
.collect())
}

/// Publish the caller's own (replaced) kind:30030 set, signed as the caller.
async fn publish_own_set(client: &SproutClient, emojis: &[CustomEmoji]) -> Result<(), CliError> {
let builder = sprout_sdk::build_custom_emoji_set(emojis)
.map_err(|e| CliError::Other(format!("build_custom_emoji_set failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}

/// Add/update a shortcode in the caller's own set (read-modify-write).
async fn cmd_set(client: &SproutClient, shortcode: &str, url: &str) -> Result<(), CliError> {
let normalized = sprout_sdk::normalize_custom_emoji_shortcode(shortcode)
.map_err(|e| CliError::Other(format!("invalid shortcode: {e}")))?;
let mut emojis = fetch_own_emoji(client).await?;
emojis.retain(|e| e.shortcode != normalized);
emojis.push(CustomEmoji {
shortcode: normalized,
url: url.to_string(),
});
publish_own_set(client, &emojis).await
}

/// Remove a shortcode from the caller's own set (read-modify-write).
async fn cmd_rm(client: &SproutClient, shortcode: &str) -> Result<(), CliError> {
let normalized = sprout_sdk::normalize_custom_emoji_shortcode(shortcode)
.map_err(|e| CliError::Other(format!("invalid shortcode: {e}")))?;
let mut emojis = fetch_own_emoji(client).await?;
let before = emojis.len();
emojis.retain(|e| e.shortcode != normalized);
if emojis.len() == before {
// Nothing to remove; avoid republishing an unchanged set.
println!(
"{}",
serde_json::json!({"accepted": true, "message": "not present"})
);
return Ok(());
}
publish_own_set(client, &emojis).await
}

// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------

pub async fn dispatch(cmd: crate::EmojiCmd, client: &SproutClient) -> Result<(), CliError> {
use crate::EmojiCmd;
match cmd {
EmojiCmd::List => cmd_list(client).await,
EmojiCmd::Set { shortcode, url } => cmd_set(client, &shortcode, &url).await,
EmojiCmd::Rm { shortcode } => cmd_rm(client, &shortcode).await,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn union_dedups_by_shortcode_and_url_across_members() {
let events = vec![
serde_json::json!({
"tags": [
["d", "sprout:custom-emoji"],
["emoji", "zort", "https://example.com/zort.png"],
["emoji", "narf", "https://example.com/narf.png"]
]
}),
serde_json::json!({
"tags": [
["d", "sprout:custom-emoji"],
// exact duplicate (same shortcode+url) — collapses
["emoji", "narf", "https://example.com/narf.png"],
// same shortcode, different url — both kept (distinct pair)
["emoji", "zort", "https://example.com/zort2.png"]
]
}),
];
let emojis = union_custom_emoji(&events);
let pairs: Vec<(&str, &str)> = emojis
.iter()
.map(|e| (e.shortcode.as_str(), e.url.as_str()))
.collect();
assert_eq!(
pairs,
vec![
("narf", "https://example.com/narf.png"),
("zort", "https://example.com/zort.png"),
("zort", "https://example.com/zort2.png"),
]
);
}
}
1 change: 1 addition & 0 deletions crates/sprout-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod channels;
pub mod dms;
pub mod emoji;
pub mod feed;
pub mod mem;
pub mod messages;
Expand Down
16 changes: 13 additions & 3 deletions crates/sprout-cli/src/commands/reactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ pub async fn cmd_add_reaction(
client: &SproutClient,
event_id: &str,
emoji: &str,
emoji_url: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
let target_eid =
EventId::parse(event_id).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))?;

let builder = sprout_sdk::build_reaction(target_eid, emoji)
.map_err(|e| CliError::Other(format!("build_reaction failed: {e}")))?;
let builder = if let Some(url) = emoji_url {
sprout_sdk::build_custom_emoji_reaction(target_eid, emoji, url)
.map_err(|e| CliError::Other(format!("build_custom_emoji_reaction failed: {e}")))?
} else {
sprout_sdk::build_reaction(target_eid, emoji)
.map_err(|e| CliError::Other(format!("build_reaction failed: {e}")))?
};

let event = client.sign_event(builder)?;

Expand Down Expand Up @@ -125,7 +131,11 @@ pub async fn cmd_get_reactions(client: &SproutClient, event_id: &str) -> Result<
pub async fn dispatch(cmd: crate::ReactionsCmd, client: &SproutClient) -> Result<(), CliError> {
use crate::ReactionsCmd;
match cmd {
ReactionsCmd::Add { event, emoji } => cmd_add_reaction(client, &event, &emoji).await,
ReactionsCmd::Add {
event,
emoji,
emoji_url,
} => cmd_add_reaction(client, &event, &emoji, emoji_url.as_deref()).await,
ReactionsCmd::Remove { event, emoji } => cmd_remove_reaction(client, &event, &emoji).await,
ReactionsCmd::Get { event } => cmd_get_reactions(client, &event).await,
}
Expand Down
40 changes: 38 additions & 2 deletions crates/sprout-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ enum Cmd {
/// Add, remove, and list emoji reactions
#[command(subcommand)]
Reactions(ReactionsCmd),
/// Manage your custom emoji set (workspace palette is the union of all members' sets)
#[command(subcommand)]
Emoji(EmojiCmd),
/// List, open, and manage direct messages
#[command(subcommand)]
Dms(DmsCmd),
Expand Down Expand Up @@ -539,9 +542,12 @@ pub enum ReactionsCmd {
/// Event ID (64-char hex)
#[arg(long)]
event: String,
/// Emoji character (e.g. '👍')
/// Emoji character (e.g. '👍') or custom emoji shortcode
#[arg(long)]
emoji: String,
/// Image URL for a custom emoji reaction; when set, content becomes `:shortcode:`
#[arg(long = "emoji-url")]
emoji_url: Option<String>,
},
/// Remove an emoji reaction from a message
Remove {
Expand All @@ -560,6 +566,31 @@ pub enum ReactionsCmd {
},
}

// ---------------------------------------------------------------------------
// Custom emoji subcommands
// ---------------------------------------------------------------------------

#[derive(Subcommand)]
pub enum EmojiCmd {
/// List the workspace custom emoji palette (union of every member's set)
List,
/// Add or update a custom emoji in your own set
Set {
/// Emoji shortcode, without surrounding colons
#[arg(long)]
shortcode: String,
/// Image URL for the emoji
#[arg(long)]
url: String,
},
/// Remove a custom emoji from your own set
Rm {
/// Emoji shortcode, without surrounding colons
#[arg(long)]
shortcode: String,
},
}

// ---------------------------------------------------------------------------
// DMs subcommands
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1104,6 +1135,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await,
Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await,
Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await,
Cmd::Emoji(sub) => commands::emoji::dispatch(sub, &client).await,
Cmd::Dms(sub) => commands::dms::dispatch(sub, &client).await,
Cmd::Users(sub) => commands::users::dispatch(sub, &client, &cli.format).await,
Cmd::Workflows(sub) => commands::workflows::dispatch(sub, &client).await,
Expand Down Expand Up @@ -1138,6 +1170,7 @@ mod tests {
"canvas",
"channels",
"dms",
"emoji",
"feed",
"mem",
"messages",
Expand Down Expand Up @@ -1217,13 +1250,15 @@ mod tests {
"members",
"purpose",
"remove-member",
"search",
"topic",
"unarchive",
"update"
]
);
assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]);
assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]);
assert_eq!(names(&cmd, "emoji"), vec!["list", "rm", "set"]);
assert_eq!(names(&cmd, "dms"), vec!["add-member", "list", "open"]);
assert_eq!(
names(&cmd, "users"),
Expand Down Expand Up @@ -1255,8 +1290,9 @@ mod tests {
fn subcommand_counts_are_stable() {
let expected: Vec<(&str, usize)> = vec![
("canvas", 2),
("channels", 14),
("channels", 15),
("dms", 3),
("emoji", 3),
("feed", 1),
("messages", 8),
("pack", 2),
Expand Down
14 changes: 13 additions & 1 deletion crates/sprout-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pub const KIND_NIP65_RELAY_LIST_METADATA: u32 = 10002;
/// 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: Emoji list (replaceable) — user preferred emojis and pointers to emoji sets.
pub const KIND_EMOJI_LIST: u32 = 10030;
/// 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
Expand All @@ -39,6 +41,15 @@ pub const KIND_FOLLOW_SET: u32 = 30000;
///
/// User-owned, keyed by `(pubkey, kind, d_tag)`.
pub const KIND_BOOKMARK_SET: u32 = 30003;
/// NIP-51 / NIP-30: Emoji set (parameterized replaceable).
///
/// User-owned, keyed by `(pubkey, kind, d_tag)`. Each member publishes their own
/// kind:30030 set (signed as themselves); the workspace emoji "palette" is the
/// client-side union of everyone's sets — a view computed on read, not stored
/// state. Ingest allowlists member-authored kind:30030/10030 (see
/// `required_scope_for_kind`), and the generic NIP-33 replace path keeps only the
/// latest per `(pubkey, d_tag)`.
pub const KIND_EMOJI_SET: u32 = 30030;
/// NIP-01: Channel metadata (replaceable). Not used by Sprout today.
pub const KIND_CHANNEL_METADATA: u32 = 41;
/// NIP-09: Event deletion request.
Expand Down Expand Up @@ -107,7 +118,6 @@ pub const RELAY_ADMIN_ADD_MEMBER: u32 = 9030;
pub const RELAY_ADMIN_REMOVE_MEMBER: u32 = 9031;
/// NIP-43: Change the role of an existing relay member.
pub const RELAY_ADMIN_CHANGE_ROLE: u32 = 9032;

// NIP-43 relay membership announcement events (relay-signed)
/// NIP-43: Relay membership list snapshot (relay-signed, replaceable by convention).
pub const KIND_NIP43_MEMBERSHIP_LIST: u32 = 13534;
Expand Down Expand Up @@ -324,8 +334,10 @@ pub const ALL_KINDS: &[u32] = &[
KIND_PIN_LIST,
KIND_NIP65_RELAY_LIST_METADATA,
KIND_BOOKMARK_LIST,
KIND_EMOJI_LIST,
KIND_FOLLOW_SET,
KIND_BOOKMARK_SET,
KIND_EMOJI_SET,
KIND_CHANNEL_METADATA,
KIND_DELETION,
KIND_REACTION,
Expand Down
Loading