Add end-to-end emoji reactions (#57)

This commit is contained in:
Wes
2026-03-13 11:29:12 -07:00
committed by GitHub
parent 771ec203e5
commit d9f8132263
19 changed files with 1401 additions and 168 deletions

View File

@@ -513,8 +513,17 @@ impl Db {
event_created_at: DateTime<Utc>,
pubkey: &[u8],
emoji: &str,
reaction_event_id: Option<&[u8]>,
) -> Result<bool> {
reaction::add_reaction(&self.pool, event_id, event_created_at, pubkey, emoji).await
reaction::add_reaction(
&self.pool,
event_id,
event_created_at,
pubkey,
emoji,
reaction_event_id,
)
.await
}
/// Soft-delete a reaction.
@@ -528,6 +537,26 @@ impl Db {
reaction::remove_reaction(&self.pool, event_id, event_created_at, pubkey, emoji).await
}
/// Soft-delete a reaction by the reaction event's own event ID.
pub async fn remove_reaction_by_source_event_id(
&self,
reaction_event_id: &[u8],
) -> Result<bool> {
reaction::remove_reaction_by_source_event_id(&self.pool, reaction_event_id).await
}
/// Look up the active reaction row for one actor + emoji + target tuple.
pub async fn get_active_reaction_record(
&self,
event_id: &[u8],
event_created_at: DateTime<Utc>,
pubkey: &[u8],
emoji: &str,
) -> Result<Option<reaction::ActiveReactionRecord>> {
reaction::get_active_reaction_record(&self.pool, event_id, event_created_at, pubkey, emoji)
.await
}
/// Get all active reactions for an event, grouped by emoji.
pub async fn get_reactions(
&self,

View File

@@ -49,6 +49,13 @@ pub struct ReactionSummary {
pub count: i64,
}
/// Active reaction row metadata for a specific actor + emoji + target tuple.
#[derive(Debug, Clone)]
pub struct ActiveReactionRecord {
/// Nostr event ID of the reaction event, if this row came from a real kind:7 event.
pub reaction_event_id: Option<Vec<u8>>,
}
// ── Write operations ──────────────────────────────────────────────────────────
/// Add (or re-activate) a reaction.
@@ -68,20 +75,26 @@ pub async fn add_reaction(
event_created_at: DateTime<Utc>,
pubkey: &[u8],
emoji: &str,
reaction_event_id: Option<&[u8]>,
) -> Result<bool> {
let result = sqlx::query(
r#"
INSERT INTO reactions (event_created_at, event_id, pubkey, emoji)
VALUES (?, ?, ?, ?)
INSERT INTO reactions (event_created_at, event_id, pubkey, emoji, reaction_event_id)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
created_at = IF(removed_at IS NOT NULL, NOW(6), created_at),
removed_at = NULL
removed_at = NULL,
reaction_event_id = CASE
WHEN removed_at IS NOT NULL THEN VALUES(reaction_event_id)
ELSE COALESCE(reaction_event_id, VALUES(reaction_event_id))
END
"#,
)
.bind(event_created_at)
.bind(event_id)
.bind(pubkey)
.bind(emoji)
.bind(reaction_event_id)
.execute(pool)
.await?;
@@ -121,6 +134,63 @@ pub async fn remove_reaction(
Ok(result.rows_affected() > 0)
}
/// Soft-delete a reaction by the reaction event's own ID.
///
/// Returns `true` if a row was updated, `false` if not found or already removed.
pub async fn remove_reaction_by_source_event_id(
pool: &MySqlPool,
reaction_event_id: &[u8],
) -> Result<bool> {
let result = sqlx::query(
r#"
UPDATE reactions
SET removed_at = NOW(6)
WHERE reaction_event_id = ?
AND removed_at IS NULL
"#,
)
.bind(reaction_event_id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// Look up the active reaction row for one actor + emoji + target tuple.
pub async fn get_active_reaction_record(
pool: &MySqlPool,
event_id: &[u8],
event_created_at: DateTime<Utc>,
pubkey: &[u8],
emoji: &str,
) -> Result<Option<ActiveReactionRecord>> {
let row = sqlx::query(
r#"
SELECT reaction_event_id
FROM reactions
WHERE event_id = ?
AND event_created_at = ?
AND pubkey = ?
AND emoji = ?
AND removed_at IS NULL
LIMIT 1
"#,
)
.bind(event_id)
.bind(event_created_at)
.bind(pubkey)
.bind(emoji)
.fetch_optional(pool)
.await?;
row.map(|row| -> Result<ActiveReactionRecord> {
Ok(ActiveReactionRecord {
reaction_event_id: row.try_get("reaction_event_id")?,
})
})
.transpose()
}
// ── Read operations ───────────────────────────────────────────────────────────
/// Get all active reactions for an event, grouped by emoji.

View File

@@ -91,7 +91,12 @@ async fn main() {
// ── Init translator ───────────────────────────────────────────────────────
let translator = Arc::new(Translator::new(shadow_keys, channel_map.clone()));
let translator = Arc::new(Translator::new(
shadow_keys,
channel_map.clone(),
api_base.clone(),
api_token.clone(),
));
// ── Init guest store (empty — guests registered via POST /admin/guests) ────

View File

@@ -396,7 +396,11 @@ async fn handle_ws(mut socket: WebSocket, state: ProxyState, token: String) {
// Strip the connection prefix before sending to client.
let client_sub_id = SubscriptionId::new(&sub_str[conn_prefix.len() + 1..]);
// Translate outbound: kind:40001 → kind:42, #h → #e
match state.translator.translate_outbound(&event, &allowed_channels) {
match state
.translator
.translate_outbound(&event, &allowed_channels)
.await
{
Ok(Some(translated)) => {
let out = RelayMessage::event(client_sub_id, translated);
if socket.send(Message::Text(out.as_json().into())).await.is_err() {
@@ -1051,6 +1055,8 @@ mod tests {
let translator = Arc::new(crate::translate::Translator::new(
shadow_keys,
channel_map.clone(),
"http://localhost:3000",
"sprout_test",
));
let upstream = Arc::new(UpstreamClient::new("ws://localhost:3000", "sprout_test"));
ProxyState {

View File

@@ -16,13 +16,15 @@
use std::sync::Arc;
use dashmap::DashMap;
use nostr::prelude::*;
use sprout_core::kind::{
event_kind_u32, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_V2,
event_kind_u32, KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_EDIT,
KIND_STREAM_MESSAGE_V2,
};
use uuid::Uuid;
use crate::channel_map::ChannelMap;
use crate::channel_map::{ChannelInfo, ChannelMap};
use crate::kind_translator::KindTranslator;
use crate::shadow_keys::ShadowKeyManager;
use crate::ProxyError;
@@ -38,15 +40,32 @@ pub struct Translator {
kind_translator: KindTranslator,
shadow_keys: Arc<ShadowKeyManager>,
channel_map: Arc<ChannelMap>,
http_client: reqwest::Client,
api_base: String,
api_token: String,
internal_to_external_event_ids: DashMap<String, String>,
external_to_internal_event_ids: DashMap<String, String>,
internal_event_channels: DashMap<String, Uuid>,
}
impl Translator {
/// Create a new [`Translator`].
pub fn new(shadow_keys: Arc<ShadowKeyManager>, channel_map: Arc<ChannelMap>) -> Self {
pub fn new(
shadow_keys: Arc<ShadowKeyManager>,
channel_map: Arc<ChannelMap>,
api_base: impl Into<String>,
api_token: impl Into<String>,
) -> Self {
Self {
kind_translator: KindTranslator::new(),
shadow_keys,
channel_map,
http_client: reqwest::Client::new(),
api_base: api_base.into(),
api_token: api_token.into(),
internal_to_external_event_ids: DashMap::new(),
external_to_internal_event_ids: DashMap::new(),
internal_event_channels: DashMap::new(),
}
}
}
@@ -54,6 +73,239 @@ impl Translator {
// ─── Outbound translation (Sprout → NIP-28 client) ───────────────────────────
impl Translator {
fn cache_event_mapping(&self, internal_event_id: &str, external_event_id: &str) {
self.internal_to_external_event_ids
.insert(internal_event_id.to_string(), external_event_id.to_string());
self.external_to_internal_event_ids
.insert(external_event_id.to_string(), internal_event_id.to_string());
}
fn lookup_external_event_id(&self, internal_event_id: &str) -> Option<String> {
self.internal_to_external_event_ids
.get(internal_event_id)
.map(|value| value.value().clone())
}
fn lookup_internal_event_id(&self, external_event_id: &str) -> Option<String> {
self.external_to_internal_event_ids
.get(external_event_id)
.map(|value| value.value().clone())
}
fn cache_internal_channel(&self, internal_event_id: &str, channel_uuid: Uuid) {
self.internal_event_channels
.insert(internal_event_id.to_string(), channel_uuid);
}
fn lookup_internal_channel(&self, internal_event_id: &str) -> Option<Uuid> {
self.internal_event_channels
.get(internal_event_id)
.map(|value| *value.value())
}
fn resolve_shadow_author_hex(&self, event: &Event) -> String {
event.pubkey.to_hex()
}
fn resolve_channel_info(
&self,
event: &Event,
allowed_channels: &[Uuid],
) -> Result<Option<(String, ChannelInfo)>, ProxyError> {
let Some((uuid_str, channel_info)) = event
.tags
.iter()
.filter(|tag| {
let slice = tag.as_slice();
slice.len() >= 2 && slice[0] == "h"
})
.find_map(|tag| {
let uuid_str = tag.as_slice().get(1)?.clone();
let uuid: Uuid = uuid_str.parse().ok()?;
let info = self.channel_map.lookup_by_uuid(&uuid)?;
Some((uuid_str, info))
})
else {
return Ok(None);
};
if !allowed_channels.contains(&channel_info.uuid) {
return Err(ProxyError::PermissionDenied(format!(
"channel {} not in invite scope",
channel_info.uuid
)));
}
Ok(Some((uuid_str, channel_info)))
}
async fn fetch_internal_event(&self, event_id: &str) -> Result<Option<Event>, ProxyError> {
let response = self
.http_client
.get(format!("{}/api/events/{}", self.api_base, event_id))
.header("Authorization", format!("Bearer {}", self.api_token))
.send()
.await
.map_err(|e| ProxyError::Upstream(format!("event fetch failed: {e}")))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let response = response
.error_for_status()
.map_err(|e| ProxyError::Upstream(format!("event fetch failed: {e}")))?;
let body = response
.text()
.await
.map_err(|e| ProxyError::Upstream(format!("event fetch body failed: {e}")))?;
serde_json::from_str::<Event>(&body)
.map(Some)
.map_err(|e| ProxyError::Upstream(format!("event decode failed: {e}")))
}
async fn ensure_external_event_id(
&self,
internal_event_id: &str,
allowed_channels: &[Uuid],
) -> Result<Option<String>, ProxyError> {
if let Some(mapped) = self.lookup_external_event_id(internal_event_id) {
return Ok(Some(mapped));
}
let Some(event) = self.fetch_internal_event(internal_event_id).await? else {
return Ok(None);
};
let translated = self.translate_outbound_message_like(&event, allowed_channels)?;
Ok(translated.map(|translated| translated.id.to_hex()))
}
fn translate_outbound_message_like(
&self,
event: &Event,
allowed_channels: &[Uuid],
) -> Result<Option<Event>, ProxyError> {
let kind_u32 = event_kind_u32(event);
let is_stream_message =
kind_u32 == KIND_STREAM_MESSAGE || kind_u32 == KIND_STREAM_MESSAGE_V2;
let is_edit = kind_u32 == KIND_STREAM_MESSAGE_EDIT;
if !is_stream_message && !is_edit {
return Ok(None);
}
let Some((uuid_str, channel_info)) = self.resolve_channel_info(event, allowed_channels)?
else {
return Ok(None);
};
let mut new_tags: Vec<Tag> = Vec::new();
new_tags.push(
Tag::parse(&["e", &channel_info.kind40_event_id]).expect("e tag is always valid"),
);
for tag in event.tags.iter() {
let slice = tag.as_slice();
if slice.first().map(|value| value.as_str()) == Some("h")
&& slice.get(1).map(|value| value.as_str()) == Some(uuid_str.as_str())
{
continue;
}
if slice.first().map(|value| value.as_str()) == Some("actor") {
continue;
}
new_tags.push(tag.clone());
}
let standard_kind = self.kind_translator.to_standard(kind_u32);
let content = if kind_u32 == KIND_STREAM_MESSAGE_V2 {
extract_plain_text(&event.content)
} else {
event.content.clone()
};
let shadow_keys = self
.shadow_keys
.get_or_create(&self.resolve_shadow_author_hex(event))?;
let translated = EventBuilder::new(
Kind::Custom(u16::try_from(standard_kind).expect("standard kind must fit in u16")),
content,
new_tags,
)
.custom_created_at(event.created_at)
.sign_with_keys(&shadow_keys)
.map_err(|e| ProxyError::Upstream(format!("outbound signing failed: {e}")))?;
self.cache_event_mapping(&event.id.to_hex(), &translated.id.to_hex());
self.cache_internal_channel(&event.id.to_hex(), channel_info.uuid);
Ok(Some(translated))
}
async fn translate_outbound_reaction(
&self,
event: &Event,
allowed_channels: &[Uuid],
) -> Result<Option<Event>, ProxyError> {
let Some((_uuid_str, channel_info)) = self.resolve_channel_info(event, allowed_channels)?
else {
return Ok(None);
};
let mut new_tags: Vec<Tag> = Vec::new();
let mut saw_target = false;
for tag in event.tags.iter() {
let slice = tag.as_slice();
if slice.first().map(|value| value.as_str()) == Some("h") {
continue;
}
if slice.first().map(|value| value.as_str()) == Some("actor") {
continue;
}
if slice.len() >= 2
&& slice[0] == "e"
&& slice[1].len() == 64
&& slice[1].chars().all(|ch| ch.is_ascii_hexdigit())
{
let Some(external_target_id) = self
.ensure_external_event_id(&slice[1], allowed_channels)
.await?
else {
return Ok(None);
};
let mut parts = vec!["e".to_string(), external_target_id];
parts.extend(slice.iter().skip(2).cloned());
let part_refs: Vec<&str> = parts.iter().map(String::as_str).collect();
new_tags.push(Tag::parse(&part_refs).map_err(|e| {
ProxyError::Upstream(format!("reaction tag build failed: {e}"))
})?);
saw_target = true;
continue;
}
new_tags.push(tag.clone());
}
if !saw_target {
return Ok(None);
}
let shadow_keys = self
.shadow_keys
.get_or_create(&self.resolve_shadow_author_hex(event))?;
let translated = EventBuilder::new(Kind::Reaction, event.content.clone(), new_tags)
.custom_created_at(event.created_at)
.sign_with_keys(&shadow_keys)
.map_err(|e| ProxyError::Upstream(format!("outbound signing failed: {e}")))?;
self.cache_event_mapping(&event.id.to_hex(), &translated.id.to_hex());
self.cache_internal_channel(&event.id.to_hex(), channel_info.uuid);
Ok(Some(translated))
}
/// Translate a Sprout event to NIP-28 format for delivery to external clients.
///
/// Returns `Ok(Some(event))` on success, `Ok(None)` if the event should be
@@ -67,100 +319,19 @@ impl Translator {
/// - All other tags are preserved unchanged
/// - Re-signed with the shadow key for the original author's pubkey
/// - V2 rich content (JSON with `"text"` field) is unwrapped to plain text
pub fn translate_outbound(
pub async fn translate_outbound(
&self,
event: &Event,
allowed_channels: &[Uuid],
) -> Result<Option<Event>, ProxyError> {
let kind_u32 = event_kind_u32(event);
// Only translate stream messages and edits; pass through or drop everything else.
let is_stream_message =
kind_u32 == KIND_STREAM_MESSAGE || kind_u32 == KIND_STREAM_MESSAGE_V2;
let is_edit = kind_u32 == KIND_STREAM_MESSAGE_EDIT;
if !is_stream_message && !is_edit {
// Drop all non-stream, non-edit kinds. The proxy only exposes a
// strict NIP-28 surface (kind:40/41/42) to external clients.
// Passing unknown kinds through would leak raw Sprout internals.
return Ok(None);
if kind_u32 == KIND_REACTION {
return self
.translate_outbound_reaction(event, allowed_channels)
.await;
}
// Find the channel `#h` tag by looking up each `#h` value against the
// channel map. This is robust to ordering — non-channel `#h` tags (e.g.
// hashtags) may appear before the real channel tag.
let (uuid_str, uuid) = match event
.tags
.iter()
.filter(|t| {
let s = t.as_slice();
s.len() >= 2 && s[0] == "h"
})
.find_map(|t| {
let val = t.as_slice().get(1)?;
let parsed: Uuid = val.parse().ok()?;
// Verify it's a known channel, not just any UUID-shaped string.
self.channel_map.lookup_by_uuid(&parsed)?;
Some((val.clone(), parsed))
}) {
Some(pair) => pair,
None => return Ok(None), // No recognized channel tag — drop silently.
};
// Enforce channel-level access control.
if !allowed_channels.contains(&uuid) {
return Err(ProxyError::PermissionDenied(format!(
"channel {} not in invite scope",
uuid
)));
}
// Resolve the kind:40 event ID for this channel.
let channel_info = self
.channel_map
.lookup_by_uuid(&uuid)
.ok_or_else(|| ProxyError::ChannelNotFound(uuid.to_string()))?;
// Build translated tag list: replace the channel `#h` with `#e`, keep everything else.
let mut new_tags: Vec<Tag> = Vec::new();
new_tags.push(
Tag::parse(&["e", &channel_info.kind40_event_id]).expect("e tag is always valid"),
);
for tag in event.tags.iter() {
let s = tag.as_slice();
// Only strip the specific `#h` tag whose value matches the channel UUID.
// Preserve any other `#h` tags.
if s.first().map(|v| v.as_str()) == Some("h")
&& s.get(1).map(|v| v.as_str()) == Some(uuid_str.as_str())
{
continue; // Drop only the channel `#h` tag.
}
new_tags.push(tag.clone());
}
// Translate kind number.
let standard_kind = self.kind_translator.to_standard(kind_u32);
// Unwrap V2 rich content to plain text; V1 and edit content passes through.
let content = if kind_u32 == KIND_STREAM_MESSAGE_V2 {
extract_plain_text(&event.content)
} else {
event.content.clone()
};
// Re-sign with the shadow key for the original author.
let shadow_keys = self.shadow_keys.get_or_create(&event.pubkey.to_hex())?;
let translated = EventBuilder::new(
Kind::Custom(u16::try_from(standard_kind).expect("standard kind must fit in u16")),
content,
new_tags,
)
.custom_created_at(event.created_at)
.sign_with_keys(&shadow_keys)
.map_err(|e| ProxyError::Upstream(format!("outbound signing failed: {e}")))?;
Ok(Some(translated))
self.translate_outbound_message_like(event, allowed_channels)
}
}
@@ -188,14 +359,106 @@ impl Translator {
let kind_u32 = event.kind.as_u16() as u32;
// Accept kind:42 (channel message), kind:41 (channel metadata edit),
// and kind:1 (text note). Everything else is rejected.
if kind_u32 != 42 && kind_u32 != 41 && kind_u32 != 1 {
// kind:1 (text note), and kind:7 (reaction). Everything else is rejected.
if kind_u32 != 42 && kind_u32 != 41 && kind_u32 != 1 && kind_u32 != 7 {
return Err(ProxyError::PermissionDenied(format!(
"kind {} not accepted by proxy (expected 1, 41, or 42)",
"kind {} not accepted by proxy (expected 1, 7, 41, or 42)",
kind_u32
)));
}
if kind_u32 == 7 {
let target_external_id = event
.tags
.iter()
.rev()
.find_map(|tag| {
let slice = tag.as_slice();
if slice.len() >= 2
&& slice[0] == "e"
&& slice[1].len() == 64
&& slice[1].chars().all(|ch| ch.is_ascii_hexdigit())
{
Some(slice[1].clone())
} else {
None
}
})
.ok_or_else(|| {
ProxyError::PermissionDenied(
"kind:7 event must reference a target message via #e".into(),
)
})?;
let target_internal_id = self
.lookup_internal_event_id(&target_external_id)
.ok_or_else(|| {
ProxyError::PermissionDenied(
"reaction target is unknown to the proxy; fetch the message first".into(),
)
})?;
let Some(channel_uuid) = self.lookup_internal_channel(&target_internal_id) else {
return Err(ProxyError::PermissionDenied(
"reaction target is unknown to the proxy; fetch the message first".into(),
));
};
if !allowed_channels.contains(&channel_uuid) {
return Err(ProxyError::PermissionDenied(
"reaction target channel not in invite scope".into(),
));
}
let mut new_tags: Vec<Tag> =
vec![Tag::parse(&["h", &channel_uuid.to_string()]).expect("h tag is always valid")];
let mut saw_target = false;
for tag in event.tags.iter() {
let slice = tag.as_slice();
if slice.first().map(|value| value.as_str()) == Some("h") {
continue;
}
if slice.len() >= 2
&& slice[0] == "e"
&& slice[1].len() == 64
&& slice[1].chars().all(|ch| ch.is_ascii_hexdigit())
{
let Some(internal_id) = self.lookup_internal_event_id(&slice[1]) else {
return Err(ProxyError::PermissionDenied(
"reaction target is unknown to the proxy; fetch the message first"
.into(),
));
};
let mut parts = vec!["e".to_string(), internal_id];
parts.extend(slice.iter().skip(2).cloned());
let part_refs: Vec<&str> = parts.iter().map(String::as_str).collect();
new_tags.push(Tag::parse(&part_refs).map_err(|e| {
ProxyError::Upstream(format!("reaction tag build failed: {e}"))
})?);
saw_target = true;
continue;
}
new_tags.push(tag.clone());
}
if !saw_target {
return Err(ProxyError::PermissionDenied(
"kind:7 event must reference a target message via #e".into(),
));
}
let shadow_keys = self.shadow_keys.get_or_create(external_pubkey)?;
let translated = EventBuilder::new(Kind::Reaction, &event.content, new_tags)
.custom_created_at(event.created_at)
.sign_with_keys(&shadow_keys)
.map_err(|e| ProxyError::Upstream(format!("inbound signing failed: {e}")))?;
self.cache_event_mapping(&translated.id.to_hex(), &event.id.to_hex());
self.cache_internal_channel(&translated.id.to_hex(), channel_uuid);
return Ok(translated);
}
// Find the channel-reference `#e` tag by looking up each `#e` value
// against the channel map. This is more robust than assuming the first
// `#e` tag is always the channel reference — reply/thread `#e` tags may
@@ -263,6 +526,8 @@ impl Translator {
.sign_with_keys(&shadow_keys)
.map_err(|e| ProxyError::Upstream(format!("inbound signing failed: {e}")))?;
self.cache_event_mapping(&translated.id.to_hex(), &event.id.to_hex());
self.cache_internal_channel(&translated.id.to_hex(), channel_info.uuid);
Ok(translated)
}
}
@@ -417,7 +682,12 @@ mod tests {
let shadow_mgr = Arc::new(
ShadowKeyManager::new(TEST_SALT).expect("shadow key manager creation must succeed"),
);
let translator = Translator::new(shadow_mgr, channel_map.clone());
let translator = Translator::new(
shadow_mgr,
channel_map.clone(),
"http://localhost:3000",
"sprout_test",
);
// Retrieve the kind:40 event ID for the test channel.
let uuid: Uuid = TEST_UUID.parse().unwrap();
@@ -435,8 +705,8 @@ mod tests {
// ── Test 1: Outbound — kind:40001 + #h → kind:42 + #e ───────────────────
#[test]
fn outbound_translates_stream_message() {
#[tokio::test]
async fn outbound_translates_stream_message() {
let (translator, kind40_event_id) = make_translator();
let author_keys = Keys::generate();
@@ -452,6 +722,7 @@ mod tests {
let result = translator
.translate_outbound(&sprout_event, &allowed())
.await
.expect("outbound translation must not error");
let translated = result.expect("should produce a translated event");
@@ -538,8 +809,8 @@ mod tests {
// ── Test 3: Outbound — channel not in allowed_channels → PermissionDenied
#[test]
fn outbound_rejects_channel_not_in_scope() {
#[tokio::test]
async fn outbound_rejects_channel_not_in_scope() {
let (translator, _) = make_translator();
let author_keys = Keys::generate();
@@ -549,7 +820,9 @@ mod tests {
.sign_with_keys(&author_keys)
.unwrap();
let result = translator.translate_outbound(&sprout_event, &no_channels());
let result = translator
.translate_outbound(&sprout_event, &no_channels())
.await;
assert!(
matches!(result, Err(ProxyError::PermissionDenied(_))),
@@ -582,8 +855,8 @@ mod tests {
// ── Test 5: V2 content extraction ────────────────────────────────────────
#[test]
fn v2_content_plain_text_extracted() {
#[tokio::test]
async fn v2_content_plain_text_extracted() {
// extract_plain_text is a private helper; test it indirectly via outbound.
let (translator, _) = make_translator();
let author_keys = Keys::generate();
@@ -600,6 +873,7 @@ mod tests {
let translated = translator
.translate_outbound(&sprout_event, &allowed())
.await
.unwrap()
.expect("should produce translated event");
@@ -806,8 +1080,8 @@ mod tests {
// ── Test 8: Outbound — non-channel #h tags are preserved (FIX 2) ────────
#[test]
fn outbound_preserves_non_channel_h_tags() {
#[tokio::test]
async fn outbound_preserves_non_channel_h_tags() {
let (translator, kind40_event_id) = make_translator();
let author_keys = Keys::generate();
@@ -825,6 +1099,7 @@ mod tests {
let result = translator
.translate_outbound(&sprout_event, &allowed())
.await
.expect("outbound translation must not error");
let translated = result.expect("should produce a translated event");
@@ -940,8 +1215,8 @@ mod tests {
// ── Test 12: Outbound — kind:40003 (edit) → kind:41 (FIX 4) ─────────────
#[test]
fn outbound_translates_edit_message() {
#[tokio::test]
async fn outbound_translates_edit_message() {
use sprout_core::kind::KIND_STREAM_MESSAGE_EDIT;
let (translator, _) = make_translator();
@@ -958,6 +1233,7 @@ mod tests {
let result = translator
.translate_outbound(&sprout_event, &allowed())
.await
.expect("outbound translation of edit must not error");
let translated = result.expect("edit must produce a translated event, not None");
@@ -1027,8 +1303,8 @@ mod tests {
// ── Test 15: Outbound — unknown kinds are dropped (not leaked) ───────
#[test]
fn outbound_drops_unknown_kinds() {
#[tokio::test]
async fn outbound_drops_unknown_kinds() {
let (translator, _) = make_translator();
let author_keys = Keys::generate();
@@ -1040,6 +1316,7 @@ mod tests {
let result = translator
.translate_outbound(&event, &allowed())
.await
.expect("outbound must not error");
assert!(

View File

@@ -5,13 +5,6 @@
//! DELETE /api/messages/:event_id/reactions/:emoji — remove own reaction
//! GET /api/messages/:event_id/reactions — list reactions
//!
//! NOTE FOR ORCHESTRATOR: `db/lib.rs` needs the following method wrappers on `Db`:
//! - `add_reaction(event_id, event_created_at, pubkey, emoji) -> Result<bool>`
//! - `remove_reaction(event_id, event_created_at, pubkey, emoji) -> Result<bool>`
//! - `get_reactions(event_id, event_created_at, limit, cursor) -> Result<Vec<ReactionGroup>>`
//! - `get_reactions_bulk(event_ids) -> Result<Vec<BulkReactionEntry>>`
//! All delegate to `sprout_db::reaction::*` free functions with `&self.pool`.
use std::collections::HashMap;
use std::sync::Arc;
@@ -22,8 +15,10 @@ use axum::{
};
use chrono::{TimeZone, Utc};
use nostr::util::hex as nostr_hex;
use nostr::{EventBuilder, Kind, Tag};
use serde::Deserialize;
use crate::handlers::event::dispatch_persistent_event;
use crate::state::AppState;
use super::{
@@ -69,6 +64,17 @@ fn decode_event_id(hex: &str) -> Result<Vec<u8>, (StatusCode, Json<serde_json::V
})
}
fn build_actor_tags(
user_pubkey_hex: &str,
) -> Result<Vec<Tag>, (StatusCode, Json<serde_json::Value>)> {
Ok(vec![
Tag::parse(&["p", user_pubkey_hex])
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
Tag::parse(&["actor", user_pubkey_hex])
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
])
}
// ── POST /api/messages/:event_id/reactions ────────────────────────────────────
/// Add a reaction to a message.
@@ -122,21 +128,60 @@ pub async fn add_reaction_handler(
.single()
.unwrap_or_default();
let added = state
if state
.db
.add_reaction(&event_id_bytes, event_created_at, &pubkey_bytes, &emoji)
.get_active_reaction_record(&event_id_bytes, event_created_at, &pubkey_bytes, &emoji)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?
.is_some()
{
return Err(api_error(StatusCode::CONFLICT, "reaction already exists"));
}
let user_pubkey_hex = nostr_hex::encode(&pubkey_bytes);
let mut tags = build_actor_tags(&user_pubkey_hex)?;
tags.push(
Tag::parse(&["e", &event_id_hex])
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
);
if let Some(channel_id) = stored.channel_id {
tags.push(
Tag::parse(&["h", &channel_id.to_string()])
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
);
}
let event = EventBuilder::new(Kind::Reaction, &emoji, tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| internal_error(&format!("event signing error: {e}")))?;
let event_id = event.id.to_hex();
let (stored_event, was_inserted) = state
.db
.insert_event(&event, stored.channel_id)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
if added {
Ok((
StatusCode::CREATED,
Json(serde_json::json!({ "added": true })),
))
} else {
// ON DUPLICATE KEY UPDATE fired — reaction already active.
Err(api_error(StatusCode::CONFLICT, "reaction already exists"))
if !was_inserted {
return Err(api_error(
StatusCode::CONFLICT,
"reaction event already exists",
));
}
crate::handlers::side_effects::handle_side_effects(7, &event, &state)
.await
.map_err(|e| internal_error(&format!("reaction side effect failed: {e}")))?;
let _ = dispatch_persistent_event(&state, &stored_event, 7, &user_pubkey_hex).await;
Ok((
StatusCode::CREATED,
Json(serde_json::json!({
"added": true,
"event_id": event_id,
})),
))
}
// ── DELETE /api/messages/:event_id/reactions/:emoji ───────────────────────────
@@ -185,17 +230,83 @@ pub async fn remove_reaction_handler(
.single()
.unwrap_or_default();
let reaction_record = state
.db
.get_active_reaction_record(&event_id_bytes, event_created_at, &pubkey_bytes, &emoji)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
let Some(reaction_record) = reaction_record else {
return Err(not_found("reaction not found"));
};
if let Some(reaction_event_id) = reaction_record.reaction_event_id {
let reaction_event_hex = nostr_hex::encode(&reaction_event_id);
let reaction_event = state
.db
.get_event_by_id(&reaction_event_id)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?
.ok_or_else(|| not_found("reaction event not found"))?;
let user_pubkey_hex = nostr_hex::encode(&pubkey_bytes);
let mut tags = build_actor_tags(&user_pubkey_hex)?;
tags.push(
Tag::parse(&["e", &reaction_event_hex])
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
);
if let Some(channel_id) = reaction_event.channel_id {
tags.push(
Tag::parse(&["h", &channel_id.to_string()])
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
);
}
let deletion = EventBuilder::new(Kind::EventDeletion, "", tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| internal_error(&format!("event signing error: {e}")))?;
let deletion_event_id = deletion.id.to_hex();
let (stored_deletion, was_inserted) = state
.db
.insert_event(&deletion, reaction_event.channel_id)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
if !was_inserted {
return Err(api_error(
StatusCode::CONFLICT,
"reaction deletion event already exists",
));
}
crate::handlers::side_effects::handle_side_effects(5, &deletion, &state)
.await
.map_err(|e| internal_error(&format!("reaction deletion failed: {e}")))?;
let _ = dispatch_persistent_event(&state, &stored_deletion, 5, &user_pubkey_hex).await;
return Ok(Json(serde_json::json!({
"removed": true,
"event_id": deletion_event_id,
})));
}
// Legacy fallback for reactions created before source-event tracking existed.
let removed = state
.db
.remove_reaction(&event_id_bytes, event_created_at, &pubkey_bytes, &emoji)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
if removed {
Ok(Json(serde_json::json!({ "removed": true })))
} else {
Err(not_found("reaction not found"))
if !removed {
return Err(not_found("reaction not found"));
}
Ok(Json(serde_json::json!({
"removed": true,
"legacy": true,
})))
}
// ── GET /api/messages/:event_id/reactions ────────────────────────────────────

View File

@@ -10,7 +10,7 @@ use sprout_audit::{AuditAction, NewAuditEntry};
use sprout_core::event::StoredEvent;
use sprout_core::kind::{
event_kind_u32, is_ephemeral, is_workflow_execution_kind, KIND_AUTH, KIND_CANVAS,
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE,
KIND_DELETION, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE,
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,
@@ -289,6 +289,19 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
}
}
if kind_u32 == KIND_DELETION {
if let Err(e) =
crate::handlers::side_effects::validate_standard_deletion_event(&event, &state).await
{
conn.send(RelayMessage::ok(
&event_id_hex,
false,
&format!("invalid: {e}"),
));
return;
}
}
// Reject reactions (kind 7) targeting archived channels before storage.
// This prevents invalid events from being stored and fanned out.
if kind_u32 == 7 {

View File

@@ -6,6 +6,7 @@ use nostr::{Event, EventBuilder, Kind, Tag};
use tracing::{info, warn};
use uuid::Uuid;
use sprout_core::kind::KIND_REACTION;
use sprout_db::channel::MemberRole;
use crate::state::AppState;
@@ -17,7 +18,7 @@ pub fn is_admin_kind(kind: u32) -> bool {
/// Check if a kind triggers side effects after storage.
pub fn is_side_effect_kind(kind: u32) -> bool {
matches!(kind, 0 | 7 | 9000..=9022 | 41001..=41003 | 40099)
matches!(kind, 0 | 5 | 7 | 9000..=9022 | 41001..=41003 | 40099)
}
/// Dispatch side effects for a stored event.
@@ -28,6 +29,7 @@ pub async fn handle_side_effects(
) -> anyhow::Result<()> {
match kind {
0 => handle_kind0_profile(event, state).await,
5 => handle_standard_deletion_event(event, state).await,
9000 => handle_put_user(event, state).await,
9001 => handle_remove_user(event, state).await,
9002 => handle_edit_metadata(event, state).await,
@@ -47,6 +49,38 @@ pub async fn handle_side_effects(
}
}
/// Validate a standard NIP-09 deletion event before it is stored.
///
/// Sprout accepts standard deletions for self-authored events only. Channel
/// admin deletions continue to use kind 9005.
pub async fn validate_standard_deletion_event(
event: &Event,
state: &Arc<AppState>,
) -> anyhow::Result<()> {
let actor_bytes = effective_message_author(event, &state.relay_keypair.public_key());
let target_ids = extract_target_event_ids(event);
if target_ids.is_empty() {
return Err(anyhow::anyhow!("missing e tag for target event"));
}
for target_id in target_ids {
let target_event = state
.db
.get_event_by_id_including_deleted(&target_id)
.await?
.ok_or_else(|| anyhow::anyhow!("target event not found"))?;
let target_author =
effective_message_author(&target_event.event, &state.relay_keypair.public_key());
if target_author != actor_bytes {
return Err(anyhow::anyhow!("must be event author"));
}
}
Ok(())
}
/// Validate an admin kind event BEFORE storage.
pub async fn validate_admin_event(
kind: u32,
@@ -740,7 +774,7 @@ async fn handle_reaction(event: &Event, state: &Arc<AppState>) -> anyhow::Result
chrono::DateTime::from_timestamp(target_event.event.created_at.as_u64() as i64, 0)
.unwrap_or_else(chrono::Utc::now);
let pubkey_bytes = event.pubkey.serialize().to_vec();
let pubkey_bytes = effective_message_author(event, &state.relay_keypair.public_key());
let emoji = if event.content.is_empty() {
"+"
} else {
@@ -749,13 +783,66 @@ async fn handle_reaction(event: &Event, state: &Arc<AppState>) -> anyhow::Result
state
.db
.add_reaction(&target_id, event_created_at, &pubkey_bytes, emoji)
.add_reaction(
&target_id,
event_created_at,
&pubkey_bytes,
emoji,
Some(event.id.as_bytes()),
)
.await?;
info!(target = %target_hex, emoji = %emoji, "NIP-25 reaction processed");
Ok(())
}
async fn handle_standard_deletion_event(
event: &Event,
state: &Arc<AppState>,
) -> anyhow::Result<()> {
let target_ids = extract_target_event_ids(event);
if target_ids.is_empty() {
return Err(anyhow::anyhow!("missing e tag for target event"));
}
for target_id in target_ids {
let target_event = match state
.db
.get_event_by_id_including_deleted(&target_id)
.await?
{
Some(target) => target,
None => continue,
};
let meta = state.db.get_thread_metadata_by_event(&target_id).await?;
let parent_id = meta.as_ref().and_then(|m| m.parent_event_id.clone());
let root_id = meta.as_ref().and_then(|m| m.root_event_id.clone());
let deleted = state
.db
.soft_delete_event_and_update_thread(
&target_id,
parent_id.as_deref(),
root_id.as_deref(),
)
.await?;
if !deleted {
continue;
}
if u32::from(target_event.event.kind.as_u16()) == KIND_REACTION {
let _ = state
.db
.remove_reaction_by_source_event_id(&target_id)
.await?;
}
}
Ok(())
}
// ── Tag Helpers ──────────────────────────────────────────────────────────────
/// Extract channel UUID from `h` tag (NIP-29 group ID).
@@ -795,6 +882,13 @@ fn extract_p_tag(event: &Event) -> Option<Vec<u8>> {
/// the author. Returns the correct author bytes in both cases.
fn effective_message_author(event: &Event, relay_pubkey: &nostr::PublicKey) -> Vec<u8> {
if event.pubkey == *relay_pubkey {
if let Some(actor_hex) = extract_tag_value(event, "actor") {
if let Ok(bytes) = hex::decode(actor_hex) {
if bytes.len() == 32 {
return bytes;
}
}
}
for tag in event.tags.iter() {
if tag.kind().to_string() == "p" {
if let Some(hex) = tag.content() {
@@ -810,6 +904,26 @@ fn effective_message_author(event: &Event, relay_pubkey: &nostr::PublicKey) -> V
event.pubkey.serialize().to_vec()
}
fn extract_target_event_ids(event: &Event) -> Vec<Vec<u8>> {
event
.tags
.iter()
.filter_map(|tag| {
if tag.kind().to_string() != "e" {
return None;
}
tag.content().and_then(|value| {
if value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit()) {
hex::decode(value).ok().filter(|bytes| bytes.len() == 32)
} else {
None
}
})
})
.collect()
}
/// Extract value of a named tag.
fn extract_tag_value(event: &Event, tag_name: &str) -> Option<String> {
for tag in event.tags.iter() {

View File

@@ -570,8 +570,29 @@ pub async fn dispatch_action(
AddReaction { emoji } => {
info!(run_id = %run_id, step = step_id, "AddReaction → :{emoji}:");
// TODO (WF-07): emit reaction event.
Ok(StepResult::Completed(serde_json::json!({ "added": true })))
if trigger_ctx.message_id.is_empty() {
return Err(WorkflowError::InvalidDefinition(
"AddReaction: no trigger.message_id available".into(),
));
}
#[cfg(feature = "reqwest")]
{
let result = add_reaction_impl(&trigger_ctx.message_id, emoji).await?;
Ok(StepResult::Completed(result))
}
#[cfg(not(feature = "reqwest"))]
{
warn!(
run_id = %run_id,
step = step_id,
"AddReaction: reqwest feature not enabled, skipping HTTP call"
);
Ok(StepResult::Completed(
serde_json::json!({ "added": false, "skipped": true }),
))
}
}
CallWebhook {
@@ -905,6 +926,61 @@ async fn send_message_impl(channel_id: &str, text: &str) -> Result<JsonValue, Wo
}))
}
/// POST `{"emoji": emoji}` to `POST /api/messages/{message_id}/reactions`.
#[cfg(feature = "reqwest")]
async fn add_reaction_impl(message_id: &str, emoji: &str) -> Result<JsonValue, WorkflowError> {
use reqwest::Client;
use std::time::Duration;
let base_url = std::env::var("SPROUT_RELAY_BASE_URL")
.unwrap_or_else(|_| "http://localhost:3000".to_owned());
let url = format!("{base_url}/api/messages/{message_id}/reactions");
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| WorkflowError::WebhookError(format!("failed to build HTTP client: {e}")))?;
let mut req = client
.post(&url)
.header("Content-Type", "application/json")
.json(&serde_json::json!({ "emoji": emoji }));
if let Ok(token) = std::env::var("SPROUT_API_TOKEN") {
req = req.header("Authorization", format!("Bearer {token}"));
} else if let Ok(pubkey) = std::env::var("SPROUT_RELAY_PUBKEY") {
req = req.header("X-Pubkey", pubkey);
}
let resp = req
.send()
.await
.map_err(|e| WorkflowError::WebhookError(format!("AddReaction HTTP error: {e}")))?;
let status = resp.status();
if !status.is_success() {
let body = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable>".to_owned());
return Err(WorkflowError::WebhookError(format!(
"AddReaction: relay returned {status} for message {message_id}: {body}"
)));
}
let body_text = resp.text().await.unwrap_or_else(|_| String::new());
let body_json: JsonValue = serde_json::from_str(&body_text)
.unwrap_or_else(|_| serde_json::json!({ "raw": body_text }));
Ok(serde_json::json!({
"added": true,
"status": status.as_u16(),
"response": body_json,
}))
}
// ── Execution result ──────────────────────────────────────────────────────────
/// Rich return type from `execute_run` / `execute_from_step`.

View File

@@ -598,6 +598,19 @@ pub fn build_trigger_context(event: &sprout_core::StoredEvent) -> executor::Trig
let kind_u32 = event_kind_u32(&event.event);
let content = event.event.content.clone();
let author = event
.event
.tags
.iter()
.find_map(|tag| {
if tag.kind().to_string() == "actor" {
tag.content().map(|value| value.to_string())
} else {
None
}
})
.unwrap_or_else(|| event.event.pubkey.to_hex());
// For reaction events (NIP-25), the content field holds the emoji character
// or shortcode (e.g. "👍", "+", "-"). Expose it as `emoji`.
let emoji = if kind_u32 == KIND_REACTION {
@@ -639,7 +652,7 @@ pub fn build_trigger_context(event: &sprout_core::StoredEvent) -> executor::Trig
executor::TriggerContext {
text: content,
author: event.event.pubkey.to_hex(),
author,
channel_id: event
.channel_id
.map(|id| id.to_string())

View File

@@ -187,6 +187,11 @@ struct SendChannelMessageBody<'a> {
broadcast_to_channel: bool,
}
#[derive(Serialize)]
struct AddReactionBody<'a> {
emoji: &'a str,
}
#[derive(Serialize)]
struct MintTokenBody<'a> {
name: &'a str,
@@ -302,6 +307,31 @@ fn default_true() -> bool {
true
}
fn percent_encode(input: &str) -> String {
let mut encoded = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char);
}
_ => {
let high = char::from_digit((byte >> 4) as u32, 16)
.expect("nibble 0-15 is always a valid hex digit")
.to_ascii_uppercase();
let low = char::from_digit((byte & 0x0f) as u32, 16)
.expect("nibble 0-15 is always a valid hex digit")
.to_ascii_uppercase();
encoded.push('%');
encoded.push(high);
encoded.push(low);
}
}
}
encoded
}
fn relay_ws_url() -> String {
std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
}
@@ -829,6 +859,37 @@ async fn send_channel_message(
send_json_request(request).await
}
#[tauri::command]
async fn add_reaction(
event_id: String,
emoji: String,
state: tauri::State<'_, AppState>,
) -> Result<(), String> {
let path = format!("/api/messages/{event_id}/reactions");
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?.json(
&AddReactionBody {
emoji: emoji.trim(),
},
);
send_empty_request(request).await
}
#[tauri::command]
async fn remove_reaction(
event_id: String,
emoji: String,
state: tauri::State<'_, AppState>,
) -> Result<(), String> {
let path = format!(
"/api/messages/{event_id}/reactions/{}",
percent_encode(emoji.trim())
);
let request = build_authed_request(&state.http_client, Method::DELETE, &path, &state)?;
send_empty_request(request).await
}
#[tauri::command]
async fn get_event(event_id: String, state: tauri::State<'_, AppState>) -> Result<String, String> {
let request = build_authed_request(
@@ -1000,6 +1061,8 @@ pub fn run() {
get_feed,
search_messages,
send_channel_message,
add_reaction,
remove_reaction,
get_event,
list_tokens,
mint_token,
@@ -1014,7 +1077,7 @@ pub fn run() {
mod tests {
use serde_json::json;
use super::ChannelInfo;
use super::{percent_encode, ChannelInfo};
#[test]
fn channel_info_defaults_is_member_for_legacy_payloads() {
@@ -1036,4 +1099,18 @@ mod tests {
assert!(channel.is_member);
}
#[test]
fn percent_encode_leaves_unreserved_chars() {
assert_eq!(
percent_encode("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"),
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"
);
}
#[test]
fn percent_encode_escapes_unicode_and_reserved_chars() {
assert_eq!(percent_encode("👍"), "%F0%9F%91%8D");
assert_eq!(percent_encode("a/b?c"), "a%2Fb%3Fc");
}
}

View File

@@ -18,6 +18,7 @@ import {
mergeMessages,
useSendMessageMutation,
useChannelSubscription,
useToggleReactionMutation,
} from "@/features/messages/hooks";
import {
collectMessageAuthorPubkeys,
@@ -126,6 +127,7 @@ export function AppShell() {
activeChannel,
identityQuery.data,
);
const toggleReactionMutation = useToggleReactionMutation();
const availableChannelIds = React.useMemo(
() => new Set(channels.map((channel) => channel.id)),
[channels],
@@ -546,6 +548,19 @@ export function AppShell() {
current === message.id ? null : message.id,
);
}}
onToggleReaction={
activeChannel &&
activeChannel.archivedAt === null &&
activeChannel.channelType !== "forum"
? async (message, emoji, remove) => {
await toggleReactionMutation.mutateAsync({
emoji,
eventId: message.id,
remove,
});
}
: undefined
}
onTargetReached={(messageId) => {
setSearchAnchor((current) =>
current?.eventId === messageId ? null : current,

View File

@@ -7,7 +7,11 @@ import {
resolveReplyRootId,
} from "@/features/messages/lib/threading";
import { relayClient } from "@/shared/api/relayClient";
import { sendChannelMessage } from "@/shared/api/tauri";
import {
addReaction,
removeReaction,
sendChannelMessage,
} from "@/shared/api/tauri";
import type { Channel, Identity, RelayEvent } from "@/shared/api/types";
type MessageQueryContext = {
@@ -278,3 +282,24 @@ export function useSendMessageMutation(
},
});
}
export function useToggleReactionMutation() {
return useMutation<
void,
Error,
{
eventId: string;
emoji: string;
remove: boolean;
}
>({
mutationFn: async ({ eventId, emoji, remove }) => {
if (remove) {
await removeReaction(eventId, emoji);
return;
}
await addReaction(eventId, emoji);
},
});
}

View File

@@ -1,13 +1,62 @@
import type { Channel, RelayEvent } from "@/shared/api/types";
import type { TimelineMessage } from "@/features/messages/types";
import type {
TimelineMessage,
TimelineReaction,
} from "@/features/messages/types";
import { getThreadReference } from "@/features/messages/lib/threading";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import {
KIND_DELETION,
KIND_REACTION,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_DIFF,
} from "@/shared/constants/kinds";
function isTimelineContentEvent(event: RelayEvent) {
return (
event.kind === KIND_STREAM_MESSAGE ||
event.kind === KIND_STREAM_MESSAGE_DIFF
);
}
function getDeletionTargets(tags: string[][]) {
return tags
.filter(
(tag) =>
tag[0] === "e" &&
typeof tag[1] === "string" &&
tag[1].length === 64 &&
/^[0-9a-f]+$/i.test(tag[1]),
)
.map((tag) => tag[1]);
}
function getReactionTargetId(tags: string[][]) {
for (let index = tags.length - 1; index >= 0; index -= 1) {
const tag = tags[index];
if (
tag?.[0] === "e" &&
typeof tag[1] === "string" &&
tag[1].length === 64 &&
/^[0-9a-f]+$/i.test(tag[1])
) {
return tag[1];
}
}
return null;
}
function getEffectiveAuthorPubkey(event: RelayEvent) {
const actorTag = event.tags.find((tag) => tag[0] === "actor")?.[1];
if (actorTag) {
return actorTag;
}
const [firstTag] = event.tags;
if (
firstTag?.[0] === "p" &&
@@ -71,7 +120,68 @@ export function formatTimelineMessages(
currentUserAvatarUrl: string | null,
profiles?: UserProfileLookup,
): TimelineMessage[] {
const eventsById = new Map(events.map((event) => [event.id, event]));
const currentPubkeyLower = currentPubkey?.toLowerCase();
const deletedEventIds = new Set<string>();
for (const event of events) {
if (event.kind !== KIND_DELETION) {
continue;
}
for (const targetId of getDeletionTargets(event.tags)) {
deletedEventIds.add(targetId);
}
}
const visibleEvents = events.filter(
(event) => isTimelineContentEvent(event) && !deletedEventIds.has(event.id),
);
const eventsById = new Map(visibleEvents.map((event) => [event.id, event]));
const reactionPresence = new Map<
string,
{
targetId: string;
actorPubkey: string;
emoji: string;
}
>();
for (const event of events) {
if (event.kind !== KIND_REACTION || deletedEventIds.has(event.id)) {
continue;
}
const targetId = getReactionTargetId(event.tags);
if (!targetId || deletedEventIds.has(targetId)) {
continue;
}
const actorPubkey = getEffectiveAuthorPubkey(event).toLowerCase();
const emoji = event.content.trim() || "+";
reactionPresence.set(`${targetId}:${actorPubkey}:${emoji}`, {
targetId,
actorPubkey,
emoji,
});
}
const reactionsByEventId = new Map<string, Map<string, TimelineReaction>>();
for (const { targetId, actorPubkey, emoji } of reactionPresence.values()) {
const current = reactionsByEventId.get(targetId) ?? new Map();
const existing = current.get(emoji) ?? {
emoji,
count: 0,
reactedByCurrentUser: false,
};
existing.count += 1;
if (currentPubkeyLower && actorPubkey === currentPubkeyLower) {
existing.reactedByCurrentUser = true;
}
current.set(emoji, existing);
reactionsByEventId.set(targetId, current);
}
const authorPubkeyByEventId = new Map<string, string>();
const authorLabelByEventId = new Map<string, string>();
const depthByEventId = new Map<string, number>();
@@ -122,7 +232,7 @@ export function formatTimelineMessages(
return depth;
}
return events.map((event) => {
return visibleEvents.map((event) => {
const author = getAuthorLabel(event);
const authorPubkey =
authorPubkeyByEventId.get(event.id) ?? getEffectiveAuthorPubkey(event);
@@ -156,6 +266,10 @@ export function formatTimelineMessages(
pending: event.pending,
kind: event.kind,
tags: event.tags,
reactions: (() => {
const reactions = reactionsByEventId.get(event.id);
return reactions ? [...reactions.values()] : undefined;
})(),
};
});
}
@@ -163,7 +277,9 @@ export function formatTimelineMessages(
export function collectMessageAuthorPubkeys(events: RelayEvent[]) {
return [
...new Set(
events.map((event) => getEffectiveAuthorPubkey(event).toLowerCase()),
events
.filter(isTimelineContentEvent)
.map((event) => getEffectiveAuthorPubkey(event).toLowerCase()),
),
];
}

View File

@@ -1,3 +1,9 @@
export type TimelineReaction = {
emoji: string;
count: number;
reactedByCurrentUser?: boolean;
};
export type TimelineMessage = {
id: string;
createdAt: number;
@@ -17,4 +23,5 @@ export type TimelineMessage = {
highlighted?: boolean;
kind?: number;
tags?: string[][];
reactions?: TimelineReaction[];
};

View File

@@ -1,12 +1,16 @@
import { ArrowDown, CornerUpLeft } from "lucide-react";
import { ArrowDown, CornerUpLeft, LoaderCircle, SmilePlus } from "lucide-react";
import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import type {
TimelineMessage,
TimelineReaction,
} from "@/features/messages/types";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Separator } from "@/shared/ui/separator";
import { Skeleton } from "@/shared/ui/skeleton";
import { DiffMessage } from "./DiffMessage";
@@ -20,11 +24,26 @@ type MessageTimelineProps = {
emptyDescription?: string;
activeReplyTargetId?: string | null;
onReply?: (message: TimelineMessage) => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
targetMessageId?: string | null;
onTargetReached?: (messageId: string) => void;
};
const BOTTOM_THRESHOLD_PX = 72;
const DEFAULT_REACTION_OPTIONS = [
"👍",
"❤️",
"🎉",
"🚀",
"👀",
"✅",
"🔥",
"👎",
];
function isNearBottom(container: HTMLDivElement) {
return (
@@ -33,48 +52,189 @@ function isNearBottom(container: HTMLDivElement) {
);
}
function getReactionOptions(reactions: TimelineReaction[]) {
const seen = new Set<string>();
const options: string[] = [];
for (const reaction of reactions) {
if (seen.has(reaction.emoji)) {
continue;
}
seen.add(reaction.emoji);
options.push(reaction.emoji);
}
for (const emoji of DEFAULT_REACTION_OPTIONS) {
if (seen.has(emoji)) {
continue;
}
seen.add(emoji);
options.push(emoji);
}
return options;
}
function MessageActionBar({
activeReplyTargetId = null,
message,
onReactionSelect,
onReply,
reactionErrorMessage = null,
reactions,
reactionPending = false,
}: {
activeReplyTargetId?: string | null;
message: TimelineMessage;
onReactionSelect?: (emoji: string) => Promise<void>;
onReply?: (message: TimelineMessage) => void;
reactionErrorMessage?: string | null;
reactions: TimelineReaction[];
reactionPending?: boolean;
}) {
if (!onReply) {
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
const hasReplyAction = Boolean(onReply);
const hasReactionAction = Boolean(onReactionSelect);
if (!hasReplyAction && !hasReactionAction) {
return null;
}
const isReplyingToMessage = activeReplyTargetId === message.id;
const selectedReactionCount = reactions.filter(
(reaction) => reaction.reactedByCurrentUser,
).length;
const reactionOptions = getReactionOptions(reactions);
return (
<div
className={cn(
"max-w-40 overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/85 transition-all duration-150 ease-out",
"max-w-20 overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/85 transition-all duration-150 ease-out",
"opacity-100 translate-y-0 sm:max-w-0 sm:opacity-0 sm:translate-y-1",
"sm:group-hover/message:max-w-40 sm:group-hover/message:opacity-100 sm:group-hover/message:translate-y-0",
"sm:group-focus-within/message:max-w-40 sm:group-focus-within/message:opacity-100 sm:group-focus-within/message:translate-y-0",
isReplyingToMessage
? "sm:max-w-40 sm:opacity-100 sm:translate-y-0"
"sm:group-hover/message:max-w-20 sm:group-hover/message:opacity-100 sm:group-hover/message:translate-y-0",
"sm:group-focus-within/message:max-w-20 sm:group-focus-within/message:opacity-100 sm:group-focus-within/message:translate-y-0",
isReplyingToMessage || isReactionPickerOpen
? "sm:max-w-20 sm:opacity-100 sm:translate-y-0"
: "",
)}
data-testid={`message-action-bar-${message.id}`}
>
<div className="flex items-center gap-1 p-1">
<Button
className="h-6 rounded-full px-2.5 text-[11px]"
data-testid={`reply-message-${message.id}`}
onClick={() => {
onReply(message);
}}
size="sm"
type="button"
variant={isReplyingToMessage ? "secondary" : "ghost"}
>
<CornerUpLeft className="h-3 w-3" />
{isReplyingToMessage ? "Replying" : "Reply"}
</Button>
{hasReactionAction ? (
<Popover
onOpenChange={setIsReactionPickerOpen}
open={isReactionPickerOpen}
>
<PopoverTrigger asChild>
<Button
aria-label="Open reactions"
className="h-6 w-6 rounded-full p-0"
data-testid={`react-message-${message.id}`}
disabled={reactionPending}
size="sm"
title="React"
type="button"
variant={
isReactionPickerOpen || selectedReactionCount > 0
? "secondary"
: "ghost"
}
>
{reactionPending ? (
<LoaderCircle className="h-3 w-3 animate-spin" />
) : (
<SmilePlus className="h-3 w-3" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-56 rounded-2xl p-3"
side="top"
sideOffset={10}
>
<div className="space-y-3">
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
React
</p>
<p
className={cn(
"text-xs",
reactionErrorMessage
? "text-destructive"
: "text-muted-foreground",
)}
>
{reactionErrorMessage ??
"Click any emoji. Click it again to remove your own reaction."}
</p>
</div>
<div className="grid grid-cols-4 gap-1">
{reactionOptions.map((emoji) => {
const isActive = reactions.some(
(reaction) =>
reaction.emoji === emoji &&
reaction.reactedByCurrentUser,
);
return (
<button
aria-label={`React with ${emoji}`}
aria-pressed={isActive}
className={cn(
"flex h-10 items-center justify-center rounded-xl border text-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "border-primary/40 bg-primary/10"
: "border-border/70 bg-muted/40 hover:bg-accent",
)}
data-emoji={emoji}
data-testid={`react-option-${message.id}`}
disabled={reactionPending}
key={`${message.id}-${emoji}`}
onClick={() => {
if (!onReactionSelect) {
return;
}
void onReactionSelect(emoji)
.then(() => {
setIsReactionPickerOpen(false);
})
.catch(() => {
return;
});
}}
type="button"
>
{emoji}
</button>
);
})}
</div>
</div>
</PopoverContent>
</Popover>
) : null}
{hasReplyAction ? (
<Button
aria-label={isReplyingToMessage ? "Cancel reply" : "Reply"}
className="h-6 w-6 rounded-full p-0"
data-testid={`reply-message-${message.id}`}
onClick={() => {
onReply?.(message);
}}
size="sm"
title={isReplyingToMessage ? "Cancel reply" : "Reply"}
type="button"
variant={isReplyingToMessage ? "secondary" : "ghost"}
>
<CornerUpLeft className="h-3 w-3" />
</Button>
) : null}
</div>
</div>
);
@@ -83,16 +243,26 @@ function MessageActionBar({
function MessageRow({
activeReplyTargetId = null,
message,
onToggleReaction,
onReply,
}: {
activeReplyTargetId?: string | null;
message: TimelineMessage;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
onReply?: (message: TimelineMessage) => void;
}) {
const [hasAvatarError, setHasAvatarError] = React.useState(false);
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
null,
);
const [reactionErrorMessage, setReactionErrorMessage] = React.useState<
string | null
>(null);
const [reactionPending, setReactionPending] = React.useState(false);
const visibleDepth = Math.min(message.depth, 6);
const compressedDepth = Math.max(message.depth - visibleDepth, 0);
const indentPx = visibleDepth * 28;
@@ -127,6 +297,44 @@ function MessageRow({
}
};
const reactions = [...(message.reactions ?? [])].sort((left, right) => {
if (left.count !== right.count) {
return right.count - left.count;
}
return left.emoji.localeCompare(right.emoji);
});
const canToggleReactions = Boolean(onToggleReaction && !message.pending);
const handleReactionSelect = React.useCallback(
async (emoji: string) => {
if (!onToggleReaction || reactionPending) {
return;
}
const remove = reactions.some(
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
);
setReactionErrorMessage(null);
setReactionPending(true);
try {
await onToggleReaction(message, emoji, remove);
} catch (error) {
const nextMessage =
error instanceof Error
? error.message
: "Failed to update the reaction.";
setReactionErrorMessage(nextMessage);
throw error;
} finally {
setReactionPending(false);
}
},
[message, onToggleReaction, reactionPending, reactions],
);
return (
<div
className="relative"
@@ -252,7 +460,13 @@ function MessageRow({
<MessageActionBar
activeReplyTargetId={activeReplyTargetId}
message={message}
onReactionSelect={
canToggleReactions ? handleReactionSelect : undefined
}
onReply={onReply}
reactionErrorMessage={reactionErrorMessage}
reactionPending={reactionPending}
reactions={reactions}
/>
{message.pending ? (
<p className="font-medium uppercase tracking-[0.14em] text-primary/80">
@@ -263,6 +477,47 @@ function MessageRow({
</div>
</div>
{renderBody()}
{reactions.length > 0 ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
{reactions.map((reaction) => (
<button
aria-label={`Toggle ${reaction.emoji} reaction`}
aria-pressed={reaction.reactedByCurrentUser}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
reaction.reactedByCurrentUser
? "border-primary/40 bg-primary/10 text-primary"
: "border-border/70 bg-muted/70 text-foreground/90",
canToggleReactions
? "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
: "cursor-default",
)}
disabled={!canToggleReactions || reactionPending}
key={`${message.id}-${reaction.emoji}`}
onClick={() => {
if (!canToggleReactions) {
return;
}
void handleReactionSelect(reaction.emoji).catch(() => {
return;
});
}}
type="button"
>
<span>{reaction.emoji}</span>
<span className="text-muted-foreground">
{reaction.count}
</span>
</button>
))}
</div>
) : null}
{reactionErrorMessage ? (
<p className="mt-2 text-xs text-destructive">
{reactionErrorMessage}
</p>
) : null}
<div className="mt-2 flex items-center gap-2">
{message.replyToSnippet ? (
<p className="truncate text-xs text-muted-foreground">
@@ -319,6 +574,7 @@ export function MessageTimeline({
emptyDescription = "Send the first message to start the thread.",
activeReplyTargetId = null,
onReply,
onToggleReaction,
targetMessageId = null,
onTargetReached,
}: MessageTimelineProps) {
@@ -679,6 +935,7 @@ export function MessageTimeline({
...message,
highlighted: message.id === highlightedMessageId,
}}
onToggleReaction={onToggleReaction}
onReply={onReply}
/>
))

View File

@@ -533,6 +533,20 @@ export async function sendChannelMessage(
};
}
export async function addReaction(
eventId: string,
emoji: string,
): Promise<void> {
await invokeTauri("add_reaction", { eventId, emoji });
}
export async function removeReaction(
eventId: string,
emoji: string,
): Promise<void> {
await invokeTauri("remove_reaction", { eventId, emoji });
}
export async function signRelayEvent(input: {
kind: number;
content: string;

View File

@@ -1,9 +1,13 @@
export const KIND_DELETION = 5;
export const KIND_REACTION = 7;
export const KIND_STREAM_MESSAGE = 40001;
export const KIND_STREAM_MESSAGE_V2 = 40002;
export const KIND_STREAM_MESSAGE_EDIT = 40003;
export const KIND_STREAM_MESSAGE_DIFF = 40008;
export const STREAM_MESSAGE_KINDS = [
KIND_DELETION, // 5 — tombstones for messages/reactions
KIND_REACTION, // 7 — NIP-25 reactions
KIND_STREAM_MESSAGE, // 40001 — was in original filter
KIND_STREAM_MESSAGE_DIFF, // 40008 — new in this PR
] as const;

View File

@@ -0,0 +1,4 @@
ALTER TABLE reactions
ADD COLUMN reaction_event_id VARBINARY(32) NULL AFTER emoji;
CREATE INDEX idx_reactions_source_event ON reactions (reaction_event_id);