mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Add end-to-end emoji reactions (#57)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) ────
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 ────────────────────────────────────
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
4
migrations/20260314000002_reaction_event_ids.sql
Normal file
4
migrations/20260314000002_reaction_event_ids.sql
Normal 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);
|
||||
Reference in New Issue
Block a user