mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add kind:40008 diff message type with structured metadata and desktop rendering (#36)
This commit is contained in:
@@ -84,6 +84,8 @@ pub const KIND_STREAM_MESSAGE_BOOKMARKED: u32 = 40005;
|
||||
pub const KIND_STREAM_MESSAGE_SCHEDULED: u32 = 40006;
|
||||
/// A reminder attached to a stream message or time.
|
||||
pub const KIND_STREAM_REMINDER: u32 = 40007;
|
||||
/// A diff/patch message showing file changes (unified diff format).
|
||||
pub const KIND_STREAM_MESSAGE_DIFF: u32 = 40008;
|
||||
/// Canvas (shared document) for a channel.
|
||||
pub const KIND_CANVAS: u32 = 40100;
|
||||
/// System message for channel state changes (join, leave, rename, etc.).
|
||||
@@ -226,6 +228,7 @@ pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_STREAM_MESSAGE_BOOKMARKED,
|
||||
KIND_STREAM_MESSAGE_SCHEDULED,
|
||||
KIND_STREAM_REMINDER,
|
||||
KIND_STREAM_MESSAGE_DIFF,
|
||||
KIND_CANVAS,
|
||||
KIND_SYSTEM_MESSAGE,
|
||||
KIND_DM_CREATED,
|
||||
|
||||
@@ -128,7 +128,7 @@ pub struct CreateWorkflowParams {
|
||||
/// UUID of the channel to own this workflow.
|
||||
pub channel_id: String,
|
||||
/// Full workflow definition in YAML format. Required fields: name (string), trigger (object with
|
||||
/// 'on' field: 'message_posted', 'reaction_added', or 'webhook'), steps (array).
|
||||
/// 'on' field: 'message_posted', 'diff_posted', 'reaction_added', or 'webhook'), steps (array).
|
||||
/// Each step needs: id (alphanumeric/underscore), action (e.g. 'send_message'), and action-specific
|
||||
/// fields as direct properties (NOT nested under 'params'). Example:
|
||||
/// ```yaml
|
||||
@@ -475,6 +475,130 @@ pub struct GetFeedActionsParams {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// Parameters for the `send_diff_message` tool.
|
||||
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct SendDiffMessageParams {
|
||||
/// UUID of the channel to post to.
|
||||
pub channel_id: String,
|
||||
/// Unified diff content (git diff format).
|
||||
pub diff: String,
|
||||
/// URL of the source repository (e.g. "https://github.com/org/repo").
|
||||
pub repo_url: String,
|
||||
/// Full commit SHA this diff applies to.
|
||||
pub commit_sha: String,
|
||||
/// Optional file path within the repo (used for language inference and display).
|
||||
#[serde(default)]
|
||||
pub file_path: Option<String>,
|
||||
/// Optional parent commit SHA (the base of the diff).
|
||||
#[serde(default)]
|
||||
pub parent_commit_sha: Option<String>,
|
||||
/// Optional source branch name (e.g. "feat/my-feature").
|
||||
#[serde(default)]
|
||||
pub source_branch: Option<String>,
|
||||
/// Optional target branch name (e.g. "main").
|
||||
#[serde(default)]
|
||||
pub target_branch: Option<String>,
|
||||
/// Optional pull request number associated with this diff.
|
||||
#[serde(default)]
|
||||
pub pr_number: Option<u32>,
|
||||
/// Optional language hint for syntax highlighting (e.g. "rust", "typescript").
|
||||
/// Inferred from file_path extension if omitted.
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
/// Optional human-readable description of the change.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// Optional parent event ID. If provided, sends the diff as a threaded reply.
|
||||
#[serde(default)]
|
||||
pub parent_event_id: Option<String>,
|
||||
}
|
||||
|
||||
// ── Diff utility functions ────────────────────────────────────────────────────
|
||||
|
||||
// Truncation notice appended when a diff is cut. This constant is used to
|
||||
// reserve space so the final result never exceeds max_bytes.
|
||||
// NOTE: This function is only called with max_bytes = 60 * 1024, so the
|
||||
// hardcoded "60KB" in the notice is intentional and always accurate.
|
||||
const TRUNCATION_NOTICE: &str =
|
||||
"\n\\ Diff truncated at 60KB. Full diff available at the source repository.";
|
||||
|
||||
/// Truncate a diff to at most `max_bytes` bytes, cutting at a hunk boundary
|
||||
/// where possible. Returns the (possibly truncated) string and a flag indicating
|
||||
/// whether truncation occurred.
|
||||
///
|
||||
/// The truncation notice is included within the `max_bytes` budget — the
|
||||
/// returned string is guaranteed to be `<= max_bytes` in length.
|
||||
fn truncate_diff(diff: &str, max_bytes: usize) -> (String, bool) {
|
||||
debug_assert!(
|
||||
max_bytes >= TRUNCATION_NOTICE.len(),
|
||||
"max_bytes ({max_bytes}) must be >= TRUNCATION_NOTICE length ({})",
|
||||
TRUNCATION_NOTICE.len()
|
||||
);
|
||||
|
||||
if diff.len() <= max_bytes {
|
||||
return (diff.to_string(), false);
|
||||
}
|
||||
|
||||
// Reserve space for the truncation notice so the final result stays within max_bytes.
|
||||
let effective_limit = max_bytes.saturating_sub(TRUNCATION_NOTICE.len());
|
||||
|
||||
// Step 1: Find the last UTF-8 char boundary at or before effective_limit
|
||||
let utf8_boundary = diff
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.take_while(|&i| i <= effective_limit)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
|
||||
// Step 2: Within safe prefix, find last complete hunk boundary
|
||||
let safe_prefix = &diff[..utf8_boundary];
|
||||
let last_hunk_start = safe_prefix.rfind("\n@@");
|
||||
|
||||
let cut_point = match last_hunk_start {
|
||||
Some(pos) if pos > 0 => pos,
|
||||
_ => safe_prefix.rfind('\n').unwrap_or(utf8_boundary),
|
||||
};
|
||||
|
||||
let mut result = diff[..cut_point].to_string();
|
||||
result.push_str(TRUNCATION_NOTICE);
|
||||
(result, true)
|
||||
}
|
||||
|
||||
/// Infer a language name from a file path's extension for syntax highlighting.
|
||||
/// Returns `None` if the extension is unknown or absent.
|
||||
fn infer_language(file_path: &str) -> Option<String> {
|
||||
// Note: rsplit always yields at least one element (the full string if no '.' found),
|
||||
// so .next() always returns Some. The ? is effectively a no-op here.
|
||||
let ext = file_path.rsplit('.').next()?;
|
||||
let lang = match ext {
|
||||
"rs" => "rust",
|
||||
"ts" | "tsx" => "typescript",
|
||||
"js" | "jsx" => "javascript",
|
||||
"py" => "python",
|
||||
"go" => "go",
|
||||
"java" => "java",
|
||||
"rb" => "ruby",
|
||||
"c" | "h" => "c",
|
||||
"cpp" | "cc" | "cxx" | "hpp" => "cpp",
|
||||
"cs" => "csharp",
|
||||
"swift" => "swift",
|
||||
"kt" | "kts" => "kotlin",
|
||||
"scala" => "scala",
|
||||
"sh" | "bash" | "zsh" => "bash",
|
||||
"sql" => "sql",
|
||||
"html" | "htm" => "html",
|
||||
"css" | "scss" | "sass" => "css",
|
||||
"json" => "json",
|
||||
"yaml" | "yml" => "yaml",
|
||||
"toml" => "toml",
|
||||
"xml" => "xml",
|
||||
"md" | "markdown" => "markdown",
|
||||
"dockerfile" => "dockerfile",
|
||||
_ => return None,
|
||||
};
|
||||
Some(lang.to_string())
|
||||
}
|
||||
|
||||
/// The MCP server that exposes Sprout relay functionality as tools.
|
||||
#[derive(Clone)]
|
||||
pub struct SproutMcpServer {
|
||||
@@ -531,6 +655,103 @@ impl SproutMcpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a code diff to a Sprout channel as kind:40008.
|
||||
#[tool(
|
||||
name = "send_diff_message",
|
||||
description = "Send a code diff to a Sprout channel with syntax highlighting and structured metadata. The diff is rendered with GitHub-quality visualization in the desktop client."
|
||||
)]
|
||||
pub async fn send_diff_message(
|
||||
&self,
|
||||
Parameters(p): Parameters<SendDiffMessageParams>,
|
||||
) -> String {
|
||||
let SendDiffMessageParams {
|
||||
channel_id,
|
||||
diff,
|
||||
repo_url,
|
||||
commit_sha,
|
||||
file_path,
|
||||
parent_commit_sha,
|
||||
source_branch,
|
||||
target_branch,
|
||||
pr_number,
|
||||
language,
|
||||
description,
|
||||
parent_event_id,
|
||||
} = p;
|
||||
|
||||
if let Err(e) = validate_uuid(&channel_id) {
|
||||
return format!("Error: {e}");
|
||||
}
|
||||
|
||||
// 1. Truncate diff at 60KB (UTF-8 safe)
|
||||
let (diff_content, truncated) = truncate_diff(&diff, 60 * 1024);
|
||||
|
||||
// 2. Infer language from file extension if not provided
|
||||
let lang = language.or_else(|| file_path.as_deref().and_then(infer_language));
|
||||
|
||||
// 3. Build NIP-31 alt tag
|
||||
let alt_text = match &description {
|
||||
Some(desc) => format!(
|
||||
"Diff: {} — {}",
|
||||
file_path.as_deref().unwrap_or("diff"),
|
||||
desc
|
||||
),
|
||||
None => format!("Diff: {}", file_path.as_deref().unwrap_or("diff")),
|
||||
};
|
||||
|
||||
// 4. Build JSON body for REST endpoint
|
||||
let mut body = serde_json::json!({
|
||||
"content": diff_content,
|
||||
"kind": 40008_u32,
|
||||
"broadcast_to_channel": false,
|
||||
"diff_repo_url": repo_url,
|
||||
"diff_commit_sha": commit_sha,
|
||||
"diff_alt": alt_text,
|
||||
});
|
||||
if let Some(ref parent) = parent_event_id {
|
||||
body["parent_event_id"] = serde_json::Value::String(parent.clone());
|
||||
}
|
||||
if let Some(ref file) = file_path {
|
||||
body["diff_file_path"] = serde_json::Value::String(file.clone());
|
||||
}
|
||||
if let Some(ref sha) = parent_commit_sha {
|
||||
body["diff_parent_commit_sha"] = serde_json::Value::String(sha.clone());
|
||||
}
|
||||
// Branch metadata — both source and target must be provided together
|
||||
match (&source_branch, &target_branch) {
|
||||
(Some(ref src), Some(ref tgt)) => {
|
||||
body["diff_source_branch"] = serde_json::Value::String(src.clone());
|
||||
body["diff_target_branch"] = serde_json::Value::String(tgt.clone());
|
||||
}
|
||||
(Some(_), None) | (None, Some(_)) => {
|
||||
// Warn caller that partial branch metadata is discarded
|
||||
tracing::warn!("send_diff_message: only one of source_branch/target_branch provided — both required, branch metadata omitted");
|
||||
}
|
||||
(None, None) => {} // Both absent — no branch tag
|
||||
}
|
||||
if let Some(pr) = pr_number {
|
||||
body["diff_pr_number"] = serde_json::json!(pr);
|
||||
}
|
||||
if let Some(ref l) = lang {
|
||||
body["diff_language"] = serde_json::Value::String(l.clone());
|
||||
}
|
||||
if let Some(ref desc) = description {
|
||||
body["diff_description"] = serde_json::Value::String(desc.clone());
|
||||
}
|
||||
if truncated {
|
||||
body["diff_truncated"] = serde_json::json!(true);
|
||||
}
|
||||
|
||||
match self
|
||||
.client
|
||||
.post(&format!("/api/channels/{}/messages", channel_id), &body)
|
||||
.await
|
||||
{
|
||||
Ok(b) => b,
|
||||
Err(e) => format!("Error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get recent messages from a Sprout channel.
|
||||
#[tool(
|
||||
name = "get_channel_history",
|
||||
@@ -1555,3 +1776,102 @@ mod tests {
|
||||
assert_eq!(MAX_CONTENT_BYTES, 65_536);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod diff_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_small_passes_through() {
|
||||
let diff = "--- a/file\n+++ b/file\n@@ -1,3 +1,3 @@\n context\n-old\n+new\n";
|
||||
let (result, truncated) = truncate_diff(diff, 60 * 1024);
|
||||
assert_eq!(result, diff);
|
||||
assert!(!truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_cuts_at_hunk_boundary() {
|
||||
// Build a diff large enough that truncation is meaningful.
|
||||
// Repeat the first hunk many times so the total is well above any
|
||||
// reasonable max_bytes, then append a second hunk we want excluded.
|
||||
let hunk_unit = "--- a/file\n+++ b/file\n@@ -1,3 +1,3 @@\n context\n-old\n+new\n";
|
||||
let mut diff = hunk_unit.repeat(20); // ~1140 bytes of first-hunk content
|
||||
diff.push_str("@@ -10,3 +10,3 @@\n more context\n-old2\n+new2\n");
|
||||
|
||||
// max_bytes sits inside the repeated first-hunk region (well below total)
|
||||
// but above TRUNCATION_NOTICE.len() so effective_limit > 0.
|
||||
// effective_limit = max_bytes - TRUNCATION_NOTICE.len() ≈ 500 - 72 = 428,
|
||||
// which lands inside the repeated first-hunk block.
|
||||
let max_bytes = 500;
|
||||
let (result, truncated) = truncate_diff(&diff, max_bytes);
|
||||
assert!(truncated);
|
||||
assert!(
|
||||
result.contains("context"),
|
||||
"should contain first-hunk content"
|
||||
);
|
||||
assert!(result.contains("Diff truncated"));
|
||||
assert!(
|
||||
!result.contains("@@ -10,3"),
|
||||
"second hunk should be excluded"
|
||||
);
|
||||
// Result must not exceed max_bytes.
|
||||
assert!(
|
||||
result.len() <= max_bytes,
|
||||
"truncated result ({}) exceeds max_bytes ({})",
|
||||
result.len(),
|
||||
max_bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_utf8_safe() {
|
||||
// Create a diff with multi-byte chars near the boundary
|
||||
let mut diff = String::from("--- a/file\n+++ b/file\n@@ -1,1 +1,1 @@\n-");
|
||||
// Add enough content to exceed a small limit, with multi-byte chars
|
||||
for _ in 0..100 {
|
||||
diff.push('日'); // 3-byte UTF-8 char
|
||||
}
|
||||
diff.push('\n');
|
||||
let (result, truncated) = truncate_diff(&diff, 80);
|
||||
assert!(truncated);
|
||||
// Must not panic and must produce valid UTF-8
|
||||
assert!(result.is_char_boundary(result.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_result_within_limit() {
|
||||
let mut diff = String::new();
|
||||
for i in 0..2000 {
|
||||
diff.push_str(&format!(
|
||||
"@@ -{i},1 +{i},1 @@\n-old line {i}\n+new line {i}\n"
|
||||
));
|
||||
}
|
||||
let max = 1024;
|
||||
let (result, truncated) = truncate_diff(&diff, max);
|
||||
assert!(truncated);
|
||||
assert!(
|
||||
result.len() <= max,
|
||||
"truncated result ({}) exceeds max_bytes ({})",
|
||||
result.len(),
|
||||
max
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_language_known_extensions() {
|
||||
assert_eq!(infer_language("src/main.rs"), Some("rust".to_string()));
|
||||
assert_eq!(infer_language("app.tsx"), Some("typescript".to_string()));
|
||||
assert_eq!(infer_language("script.py"), Some("python".to_string()));
|
||||
assert_eq!(infer_language("Makefile"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_language_no_extension() {
|
||||
assert_eq!(infer_language("Dockerfile"), None);
|
||||
// But "foo.dockerfile" should match
|
||||
assert_eq!(
|
||||
infer_language("foo.dockerfile"),
|
||||
Some("dockerfile".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,137 @@ fn reactions_to_json(reactions: &[sprout_db::reaction::ReactionSummary]) -> serd
|
||||
|
||||
// ── POST /api/channels/:channel_id/messages ───────────────────────────────────
|
||||
|
||||
/// Build Nostr tags for a kind:40008 diff message.
|
||||
///
|
||||
/// Required fields: `diff_repo_url` (must be http/https) and `diff_commit_sha`.
|
||||
/// All other diff fields are optional.
|
||||
fn build_diff_tags(
|
||||
body: &SendMessageBody,
|
||||
) -> Result<Vec<Tag>, (StatusCode, axum::Json<serde_json::Value>)> {
|
||||
// repo-url is required and must be http/https
|
||||
let repo_url = body.diff_repo_url.as_deref().ok_or_else(|| {
|
||||
api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"diff_repo_url is required for kind:40008",
|
||||
)
|
||||
})?;
|
||||
if !repo_url.starts_with("http://") && !repo_url.starts_with("https://") {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"diff_repo_url must use http or https scheme",
|
||||
));
|
||||
}
|
||||
|
||||
// commit-sha is required and must be a valid hex string (min 7 chars for short SHA)
|
||||
let commit_sha = body
|
||||
.diff_commit_sha
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"diff_commit_sha is required for kind=40008",
|
||||
)
|
||||
})?;
|
||||
if commit_sha.len() < 7 || !commit_sha.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"diff_commit_sha must be a hex string (min 7 chars)",
|
||||
));
|
||||
}
|
||||
|
||||
let mut tags: Vec<Tag> = Vec::new();
|
||||
|
||||
tags.push(
|
||||
Tag::parse(&["repo", repo_url])
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
);
|
||||
tags.push(
|
||||
Tag::parse(&["commit", commit_sha])
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
);
|
||||
|
||||
if let Some(ref file_path) = body.diff_file_path {
|
||||
tags.push(
|
||||
Tag::parse(&["file", file_path])
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(parent) = body
|
||||
.diff_parent_commit_sha
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
if parent.len() < 7 || !parent.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"diff_parent_commit_sha must be a hex string (min 7 chars)",
|
||||
));
|
||||
}
|
||||
tags.push(
|
||||
Tag::parse(&["parent-commit", parent])
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
);
|
||||
}
|
||||
|
||||
// Both source and target branch are required together
|
||||
match (&body.diff_source_branch, &body.diff_target_branch) {
|
||||
(Some(src), Some(tgt)) => {
|
||||
tags.push(
|
||||
Tag::parse(&["branch", src, tgt])
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"diff_source_branch and diff_target_branch must both be provided or both omitted",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pr_number) = body.diff_pr_number {
|
||||
let pr_str = pr_number.to_string();
|
||||
tags.push(
|
||||
Tag::parse(&["pr", &pr_str])
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref lang) = body.diff_language {
|
||||
tags.push(
|
||||
Tag::parse(&["l", lang])
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref description) = body.diff_description {
|
||||
tags.push(
|
||||
Tag::parse(&["description", description])
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(truncated) = body.diff_truncated {
|
||||
let val = if truncated { "true" } else { "false" };
|
||||
tags.push(
|
||||
Tag::parse(&["truncated", val])
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref alt) = body.diff_alt {
|
||||
tags.push(
|
||||
Tag::parse(&["alt", alt])
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// Request body for sending a channel message or thread reply.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SendMessageBody {
|
||||
@@ -114,6 +245,29 @@ pub struct SendMessageBody {
|
||||
pub broadcast_to_channel: bool,
|
||||
/// Nostr kind for this message. Defaults to `KIND_STREAM_MESSAGE` (40001).
|
||||
pub kind: Option<u32>,
|
||||
// Diff metadata fields (only used when kind == KIND_STREAM_MESSAGE_DIFF)
|
||||
/// Repository URL for the diff (required for kind:40008; must be http/https).
|
||||
pub diff_repo_url: Option<String>,
|
||||
/// File path within the repository that this diff applies to.
|
||||
pub diff_file_path: Option<String>,
|
||||
/// Commit SHA that produced this diff (required for kind:40008).
|
||||
pub diff_commit_sha: Option<String>,
|
||||
/// Parent commit SHA (the base of the diff).
|
||||
pub diff_parent_commit_sha: Option<String>,
|
||||
/// Source branch for the diff (e.g. feature branch). Must be paired with `diff_target_branch`.
|
||||
pub diff_source_branch: Option<String>,
|
||||
/// Target branch for the diff (e.g. main). Must be paired with `diff_source_branch`.
|
||||
pub diff_target_branch: Option<String>,
|
||||
/// Pull request number associated with this diff.
|
||||
pub diff_pr_number: Option<u32>,
|
||||
/// Programming language of the diffed file (e.g. `"rust"`, `"typescript"`).
|
||||
pub diff_language: Option<String>,
|
||||
/// Human-readable description of what the diff changes.
|
||||
pub diff_description: Option<String>,
|
||||
/// When `true`, the diff content was truncated to fit the 60KB limit.
|
||||
pub diff_truncated: Option<bool>,
|
||||
/// Plain-text alternative summary for clients that cannot render diffs.
|
||||
pub diff_alt: Option<String>,
|
||||
}
|
||||
|
||||
/// Send a new channel message or reply to an existing thread.
|
||||
@@ -149,6 +303,13 @@ pub async fn send_message(
|
||||
|
||||
// Resolve kind — default to KIND_STREAM_MESSAGE (40001).
|
||||
let kind_u32 = body.kind.unwrap_or(sprout_core::kind::KIND_STREAM_MESSAGE);
|
||||
|
||||
if kind_u32 == sprout_core::kind::KIND_STREAM_MESSAGE_DIFF && body.content.len() > 60 * 1024 {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"diff content exceeds 60KB limit; truncate before sending",
|
||||
));
|
||||
}
|
||||
let kind = Kind::from(kind_u32 as u16);
|
||||
|
||||
// ── Resolve thread ancestry ───────────────────────────────────────────────
|
||||
@@ -274,6 +435,11 @@ pub async fn send_message(
|
||||
);
|
||||
}
|
||||
|
||||
if body.kind == Some(sprout_core::kind::KIND_STREAM_MESSAGE_DIFF) {
|
||||
let diff_tags = build_diff_tags(&body)?;
|
||||
tags.extend(diff_tags);
|
||||
}
|
||||
|
||||
let event = EventBuilder::new(kind, &body.content, tags)
|
||||
.sign_with_keys(&state.relay_keypair)
|
||||
.map_err(|e| internal_error(&format!("event signing error: {e}")))?;
|
||||
@@ -486,6 +652,7 @@ pub async fn list_messages(
|
||||
"kind": m.kind,
|
||||
"created_at": m.created_at.timestamp(),
|
||||
"channel_id": m.channel_id.to_string(),
|
||||
"tags": m.tags,
|
||||
});
|
||||
|
||||
if let Some(ref ts) = m.thread_summary {
|
||||
@@ -618,11 +785,14 @@ pub async fn get_thread(
|
||||
let relay_pk = state.relay_keypair.public_key();
|
||||
let relay_pk_bytes = relay_pk.serialize().to_vec();
|
||||
let root_author = effective_author(&root_event.event, &relay_pk);
|
||||
let root_tags =
|
||||
serde_json::to_value(&root_event.event.tags).unwrap_or(serde_json::Value::Array(vec![]));
|
||||
let mut root_obj = serde_json::json!({
|
||||
"event_id": root_event.event.id.to_hex(),
|
||||
"pubkey": nostr_hex::encode(&root_author),
|
||||
"content": root_event.event.content,
|
||||
"kind": root_event.event.kind.as_u16(),
|
||||
"tags": root_tags,
|
||||
"created_at": root_created_at,
|
||||
"channel_id": channel_id.to_string(),
|
||||
"thread_summary": summary.as_ref().map(|s| serde_json::json!({
|
||||
@@ -677,6 +847,7 @@ pub async fn get_thread(
|
||||
"depth": r.depth,
|
||||
"created_at": r.created_at.timestamp(),
|
||||
"broadcast": r.broadcast,
|
||||
"tags": r.tags,
|
||||
});
|
||||
|
||||
if let Some(reactions) = thread_reaction_map.get(&r.event_id) {
|
||||
|
||||
@@ -11,9 +11,9 @@ 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_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_EDIT,
|
||||
KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2,
|
||||
KIND_STREAM_REMINDER,
|
||||
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,
|
||||
};
|
||||
use sprout_core::verification::verify_event;
|
||||
|
||||
@@ -581,6 +581,10 @@ fn extract_channel_id(event: &Event) -> Option<uuid::Uuid> {
|
||||
None
|
||||
}
|
||||
|
||||
// NOTE: This function only validates that channel-scoped kinds include an `h` tag.
|
||||
// Kind-specific metadata validation (e.g., diff_repo_url for kind:40008) is NOT
|
||||
// enforced on the WebSocket path — it is handled by the REST API layer (api/messages.rs).
|
||||
// This follows the Nostr protocol model where the relay is kind-agnostic for content events.
|
||||
fn requires_h_channel_scope(kind: u32) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
@@ -591,6 +595,7 @@ fn requires_h_channel_scope(kind: u32) -> bool {
|
||||
| KIND_STREAM_MESSAGE_BOOKMARKED
|
||||
| KIND_STREAM_MESSAGE_SCHEDULED
|
||||
| KIND_STREAM_REMINDER
|
||||
| KIND_STREAM_MESSAGE_DIFF
|
||||
| KIND_CANVAS
|
||||
| KIND_FORUM_POST
|
||||
| KIND_FORUM_VOTE
|
||||
@@ -603,13 +608,14 @@ mod tests {
|
||||
use super::requires_h_channel_scope;
|
||||
use sprout_core::kind::{
|
||||
KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE,
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn channel_scoped_content_kinds_require_h_tags() {
|
||||
for kind in [
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_DIFF,
|
||||
KIND_CANVAS,
|
||||
KIND_FORUM_POST,
|
||||
KIND_FORUM_VOTE,
|
||||
|
||||
@@ -295,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() {
|
||||
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
41,
|
||||
"expected exactly 41 tools, got {}. Tools: {:?}",
|
||||
42,
|
||||
"expected exactly 42 tools, got {}. Tools: {:?}",
|
||||
tools.len(),
|
||||
tools
|
||||
.iter()
|
||||
|
||||
@@ -351,6 +351,29 @@ async fn should_fire_workflow(
|
||||
}
|
||||
}
|
||||
|
||||
if let TriggerDef::DiffPosted {
|
||||
filter: Some(ref expr),
|
||||
} = def.trigger
|
||||
{
|
||||
match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
workflow_id = %workflow_id,
|
||||
"Trigger filter evaluated false — skipping workflow"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
workflow_id = %workflow_id,
|
||||
"Trigger filter error: {e} — skipping workflow"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
@@ -426,10 +449,11 @@ pub fn build_trigger_context(event: &sprout_core::StoredEvent) -> executor::Trig
|
||||
|
||||
/// Returns `true` if the trigger type matches the given event kind.
|
||||
fn trigger_matches_event(trigger: &TriggerDef, kind_u32: u32) -> bool {
|
||||
use sprout_core::kind::{KIND_REACTION, KIND_STREAM_MESSAGE};
|
||||
use sprout_core::kind::{KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF};
|
||||
match trigger {
|
||||
TriggerDef::MessagePosted { .. } => kind_u32 == KIND_STREAM_MESSAGE,
|
||||
TriggerDef::ReactionAdded { .. } => kind_u32 == KIND_REACTION,
|
||||
TriggerDef::DiffPosted { .. } => kind_u32 == KIND_STREAM_MESSAGE_DIFF,
|
||||
// Schedule and Webhook triggers are not fired by channel events.
|
||||
TriggerDef::Schedule { .. } | TriggerDef::Webhook => false,
|
||||
}
|
||||
@@ -611,6 +635,21 @@ steps:
|
||||
assert!(!trigger_matches_event(&webhook_trigger, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_posted_matches_kind_40008_only() {
|
||||
let trigger = TriggerDef::DiffPosted { filter: None };
|
||||
assert!(trigger_matches_event(&trigger, 40008));
|
||||
assert!(!trigger_matches_event(&trigger, 40001));
|
||||
assert!(!trigger_matches_event(&trigger, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_posted_does_not_match_kind_40008() {
|
||||
let trigger = TriggerDef::MessagePosted { filter: None };
|
||||
assert!(!trigger_matches_event(&trigger, 40008));
|
||||
assert!(trigger_matches_event(&trigger, 40001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_config_custom_values() {
|
||||
let cfg = WorkflowConfig {
|
||||
|
||||
@@ -52,6 +52,12 @@ pub enum TriggerDef {
|
||||
#[serde(default)]
|
||||
emoji: Option<String>,
|
||||
},
|
||||
/// Fires when a diff message (kind:40008) is posted in the workflow's channel.
|
||||
DiffPosted {
|
||||
/// Optional evalexpr filter expression (same variables as MessagePosted).
|
||||
#[serde(default)]
|
||||
filter: Option<String>,
|
||||
},
|
||||
/// Fires on a cron schedule.
|
||||
Schedule {
|
||||
/// Cron expression (UTC). Mutually exclusive with `interval`.
|
||||
@@ -832,4 +838,25 @@ mod tests {
|
||||
let result = normalize_cron("* * * * *");
|
||||
assert_eq!(result, "0 * * * * * *");
|
||||
}
|
||||
|
||||
// ── DiffPosted trigger ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn diff_posted_trigger_roundtrips_yaml() {
|
||||
let yaml = "on: diff_posted\n";
|
||||
let trigger: TriggerDef = serde_yaml::from_str(yaml).unwrap();
|
||||
assert!(matches!(trigger, TriggerDef::DiffPosted { filter: None }));
|
||||
let back = serde_yaml::to_string(&trigger).unwrap();
|
||||
assert!(back.contains("diff_posted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_posted_trigger_with_filter_roundtrips_yaml() {
|
||||
let yaml = "on: diff_posted\nfilter: 'str_contains(trigger_text, \"src/\")'\n";
|
||||
let trigger: TriggerDef = serde_yaml::from_str(yaml).unwrap();
|
||||
assert!(matches!(
|
||||
trigger,
|
||||
TriggerDef::DiffPosted { filter: Some(_) }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
@@ -29,6 +30,8 @@
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"diff2html": "^3.4.56",
|
||||
"dompurify": "^3.3.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
|
||||
Generated
+117
-5
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -38,6 +41,12 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
diff2html:
|
||||
specifier: ^3.4.56
|
||||
version: 3.4.56
|
||||
dompurify:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
lucide-react:
|
||||
specifier: ^0.577.0
|
||||
version: 0.577.0(react@19.2.4)
|
||||
@@ -432,6 +441,16 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@monaco-editor/loader@1.7.0':
|
||||
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
|
||||
|
||||
'@monaco-editor/react@4.7.0':
|
||||
resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==}
|
||||
peerDependencies:
|
||||
monaco-editor: '>= 0.25.0 < 1'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@noble/ciphers@2.1.1':
|
||||
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
@@ -461,6 +480,10 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@profoundlogic/hogan@3.0.4':
|
||||
resolution: {integrity: sha512-pmNVGuooS30Mm7YbZd5T7E5zYVO6D5Ct91sn4T39mUvMUc3sCGridcnhAufL1/Bz2QzAtzEn0agNrdk3+5yWzw==}
|
||||
hasBin: true
|
||||
|
||||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
@@ -838,7 +861,6 @@ packages:
|
||||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||
@@ -849,7 +871,6 @@ packages:
|
||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||
@@ -867,7 +888,6 @@ packages:
|
||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||
@@ -884,18 +904,17 @@ packages:
|
||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||
@@ -907,6 +926,7 @@ packages:
|
||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||
@@ -1070,6 +1090,9 @@ packages:
|
||||
'@types/react@19.2.14':
|
||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@2.0.11':
|
||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||
|
||||
@@ -1085,6 +1108,9 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
abbrev@1.1.1:
|
||||
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
||||
|
||||
any-promise@1.3.0:
|
||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||
|
||||
@@ -1203,9 +1229,23 @@ packages:
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
diff2html@3.4.56:
|
||||
resolution: {integrity: sha512-u9gfn+BlbHcyO7vItCIC4z49LJDUt31tODzOfAuJ5R1E7IdlRL6KjugcB9zOpejD+XiR+dDZbsnHSQ3g6A/u8A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
diff@8.0.3:
|
||||
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dlv@1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
|
||||
dompurify@3.2.7:
|
||||
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
|
||||
|
||||
dompurify@3.3.3:
|
||||
resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
|
||||
|
||||
electron-to-chromium@1.5.307:
|
||||
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
|
||||
|
||||
@@ -1290,6 +1330,10 @@ packages:
|
||||
hast-util-whitespace@3.0.0:
|
||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||
|
||||
highlight.js@11.11.1:
|
||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
html-url-attributes@3.0.1:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
@@ -1370,6 +1414,11 @@ packages:
|
||||
markdown-table@3.0.4:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
marked@14.0.0:
|
||||
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
|
||||
engines: {node: '>= 18'}
|
||||
hasBin: true
|
||||
|
||||
mdast-util-find-and-replace@3.0.2:
|
||||
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
|
||||
|
||||
@@ -1510,6 +1559,9 @@ packages:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
monaco-editor@0.55.1:
|
||||
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -1524,6 +1576,10 @@ packages:
|
||||
node-releases@2.0.36:
|
||||
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
|
||||
|
||||
nopt@1.0.10:
|
||||
resolution: {integrity: sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==}
|
||||
hasBin: true
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1731,6 +1787,9 @@ packages:
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
state-local@1.0.7:
|
||||
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||
|
||||
@@ -2163,6 +2222,17 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@monaco-editor/loader@1.7.0':
|
||||
dependencies:
|
||||
state-local: 1.0.7
|
||||
|
||||
'@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@monaco-editor/loader': 1.7.0
|
||||
monaco-editor: 0.55.1
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@noble/ciphers@2.1.1': {}
|
||||
|
||||
'@noble/curves@2.0.1':
|
||||
@@ -2187,6 +2257,10 @@ snapshots:
|
||||
dependencies:
|
||||
playwright: 1.58.2
|
||||
|
||||
'@profoundlogic/hogan@3.0.4':
|
||||
dependencies:
|
||||
nopt: 1.0.10
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
@@ -2702,6 +2776,9 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
@@ -2720,6 +2797,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
abbrev@1.1.1: {}
|
||||
|
||||
any-promise@1.3.0: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
@@ -2820,8 +2899,25 @@ snapshots:
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
diff2html@3.4.56:
|
||||
dependencies:
|
||||
'@profoundlogic/hogan': 3.0.4
|
||||
diff: 8.0.3
|
||||
optionalDependencies:
|
||||
highlight.js: 11.11.1
|
||||
|
||||
diff@8.0.3: {}
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
dompurify@3.2.7:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dompurify@3.3.3:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
electron-to-chromium@1.5.307: {}
|
||||
|
||||
esbuild@0.27.3:
|
||||
@@ -2931,6 +3027,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
highlight.js@11.11.1:
|
||||
optional: true
|
||||
|
||||
html-url-attributes@3.0.1: {}
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
@@ -2988,6 +3087,8 @@ snapshots:
|
||||
|
||||
markdown-table@3.0.4: {}
|
||||
|
||||
marked@14.0.0: {}
|
||||
|
||||
mdast-util-find-and-replace@3.0.2:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -3344,6 +3445,11 @@ snapshots:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.1
|
||||
|
||||
monaco-editor@0.55.1:
|
||||
dependencies:
|
||||
dompurify: 3.2.7
|
||||
marked: 14.0.0
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mz@2.7.0:
|
||||
@@ -3356,6 +3462,10 @@ snapshots:
|
||||
|
||||
node-releases@2.0.36: {}
|
||||
|
||||
nopt@1.0.10:
|
||||
dependencies:
|
||||
abbrev: 1.1.1
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
nostr-tools@2.23.3(typescript@5.8.3):
|
||||
@@ -3600,6 +3710,8 @@ snapshots:
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
state-local@1.0.7: {}
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
dependencies:
|
||||
character-entities-html4: 2.1.0
|
||||
|
||||
@@ -54,6 +54,8 @@ export function formatTimelineMessages(
|
||||
body: event.content,
|
||||
accent: currentPubkey === event.pubkey,
|
||||
pending: event.pending,
|
||||
kind: event.kind,
|
||||
tags: event.tags,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { parse } from "diff2html";
|
||||
|
||||
export function parseDiffToOldNew(unifiedDiff: string): {
|
||||
original: string;
|
||||
modified: string;
|
||||
} {
|
||||
try {
|
||||
const files = parse(unifiedDiff);
|
||||
if (!files.length) {
|
||||
// diff2html couldn't parse any files — show raw diff as fallback
|
||||
return { original: "", modified: unifiedDiff };
|
||||
}
|
||||
|
||||
const originalLines: string[] = [];
|
||||
const modifiedLines: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
// Add file header separator for multi-file diffs
|
||||
if (files.length > 1) {
|
||||
const header = `// ── ${file.newName || file.oldName || "unknown"} ──`;
|
||||
originalLines.push(header);
|
||||
modifiedLines.push(header);
|
||||
}
|
||||
|
||||
for (const block of file.blocks) {
|
||||
for (const line of block.lines) {
|
||||
if (line.content.startsWith("\\ ")) continue;
|
||||
if (line.type === "context" || line.type === "delete") {
|
||||
originalLines.push(line.content.slice(1));
|
||||
}
|
||||
if (line.type === "context" || line.type === "insert") {
|
||||
modifiedLines.push(line.content.slice(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add blank line between files
|
||||
if (files.length > 1) {
|
||||
originalLines.push("");
|
||||
modifiedLines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
original: originalLines.join("\n"),
|
||||
modified: modifiedLines.join("\n"),
|
||||
};
|
||||
} catch {
|
||||
// Malformed diff — return raw content as fallback
|
||||
return { original: "", modified: unifiedDiff };
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,6 @@ export type TimelineMessage = {
|
||||
accent?: boolean;
|
||||
pending?: boolean;
|
||||
highlighted?: boolean;
|
||||
kind?: number;
|
||||
tags?: string[][];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { html } from "diff2html";
|
||||
import "diff2html/bundles/css/diff2html.min.css";
|
||||
import DOMPurify from "dompurify";
|
||||
import { FileDiff, Maximize2 } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
/**
|
||||
* Override diff2html styles that break in constrained containers.
|
||||
* The default CSS uses position:absolute for line numbers which causes
|
||||
* overflow and misalignment in narrow message bubbles.
|
||||
*/
|
||||
const diffStyleOverrides = `
|
||||
.d2h-wrapper { text-align: left; }
|
||||
.d2h-file-wrapper { border: none; margin: 0; border-radius: 0; }
|
||||
.d2h-file-header { display: none; }
|
||||
.d2h-diff-table { font-size: 12px; width: 100%; table-layout: fixed; }
|
||||
.d2h-code-linenumber {
|
||||
position: static;
|
||||
display: table-cell;
|
||||
width: 3em;
|
||||
min-width: 3em;
|
||||
max-width: 3em;
|
||||
padding: 0 0.4em;
|
||||
text-align: right;
|
||||
vertical-align: top;
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--d2h-line-border-color, #eee);
|
||||
font-size: 11px;
|
||||
}
|
||||
.d2h-code-line {
|
||||
display: table-cell;
|
||||
padding: 0 0.5em;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
width: auto;
|
||||
}
|
||||
.d2h-code-line-ctn {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.d2h-info {
|
||||
padding: 0.2em 0.5em;
|
||||
}
|
||||
`;
|
||||
|
||||
type DiffMessageProps = {
|
||||
content: string;
|
||||
repoUrl?: string;
|
||||
filePath?: string;
|
||||
commitSha?: string;
|
||||
description?: string;
|
||||
truncated?: boolean;
|
||||
onExpand?: () => void;
|
||||
};
|
||||
|
||||
function isSafeUrl(url: string | undefined): url is string {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getHostname(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
"div",
|
||||
"span",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"pre",
|
||||
"code",
|
||||
"ins",
|
||||
"del",
|
||||
"a",
|
||||
"i",
|
||||
"em",
|
||||
"strong",
|
||||
"small",
|
||||
];
|
||||
|
||||
const ALLOWED_ATTR = [
|
||||
"class",
|
||||
"id",
|
||||
"data-line-number",
|
||||
"href",
|
||||
"title",
|
||||
"aria-label",
|
||||
];
|
||||
|
||||
const ALLOWED_URI_REGEXP = /^https?:\/\//i;
|
||||
|
||||
export function DiffMessage({
|
||||
content,
|
||||
repoUrl,
|
||||
filePath,
|
||||
commitSha,
|
||||
description,
|
||||
truncated,
|
||||
onExpand,
|
||||
}: DiffMessageProps) {
|
||||
const { diffHtml, renderError } = useMemo(() => {
|
||||
try {
|
||||
const rawHtml = html(content, {
|
||||
drawFileList: false,
|
||||
matching: "lines",
|
||||
outputFormat: "line-by-line",
|
||||
});
|
||||
const sanitized = DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
ALLOWED_URI_REGEXP,
|
||||
});
|
||||
return { diffHtml: sanitized, renderError: false };
|
||||
} catch {
|
||||
return { diffHtml: "", renderError: true };
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const safeRepoUrl = isSafeUrl(repoUrl) ? repoUrl : undefined;
|
||||
|
||||
const commitUrl =
|
||||
safeRepoUrl && commitSha ? `${safeRepoUrl}/commit/${commitSha}` : undefined;
|
||||
|
||||
const shortSha = commitSha ? commitSha.slice(0, 7) : undefined;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/70 bg-card/60 overflow-hidden text-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/50 bg-muted/40">
|
||||
<FileDiff className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate font-mono text-xs text-foreground/80">
|
||||
{filePath ?? "diff"}
|
||||
</span>
|
||||
{shortSha && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{commitUrl ? (
|
||||
<a
|
||||
className="hover:underline"
|
||||
href={commitUrl}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
{shortSha}
|
||||
</a>
|
||||
) : (
|
||||
shortSha
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{safeRepoUrl && !commitUrl && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<a
|
||||
className="hover:underline"
|
||||
href={safeRepoUrl}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
{getHostname(safeRepoUrl)}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{onExpand && (
|
||||
<Button
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onExpand}
|
||||
size="sm"
|
||||
title="Expand diff"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<div className="px-3 py-1.5 text-xs text-muted-foreground border-b border-border/40 bg-muted/20">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff content — max 400px height, scrollable */}
|
||||
<div className="max-h-[400px] overflow-auto text-xs">
|
||||
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: static CSS overrides */}
|
||||
<style dangerouslySetInnerHTML={{ __html: diffStyleOverrides }} />
|
||||
{renderError ? (
|
||||
<pre className="p-3 whitespace-pre-wrap font-mono text-muted-foreground">
|
||||
{content}
|
||||
</pre>
|
||||
) : diffHtml ? (
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized by DOMPurify
|
||||
<div dangerouslySetInnerHTML={{ __html: diffHtml }} />
|
||||
) : (
|
||||
<div className="p-3 text-xs text-muted-foreground italic">
|
||||
No diff content
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Truncation warning */}
|
||||
{truncated && (
|
||||
<div className="px-3 py-2 border-t border-border/50 bg-amber-500/10 text-xs text-amber-700 dark:text-amber-400">
|
||||
Diff truncated.{" "}
|
||||
{safeRepoUrl && commitUrl ? (
|
||||
<a
|
||||
className="underline hover:no-underline"
|
||||
href={commitUrl}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
View full diff on {getHostname(safeRepoUrl)}
|
||||
</a>
|
||||
) : (
|
||||
"View the full diff at the source repository."
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { DiffEditor } from "@monaco-editor/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { parseDiffToOldNew } from "@/features/messages/lib/parseDiff";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
|
||||
type DiffMessageExpandedProps = {
|
||||
content: string;
|
||||
language?: string;
|
||||
filePath?: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function DiffMessageExpanded({
|
||||
content,
|
||||
language,
|
||||
filePath,
|
||||
onClose,
|
||||
}: DiffMessageExpandedProps) {
|
||||
const { original, modified } = useMemo(
|
||||
() => parseDiffToOldNew(content),
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
open
|
||||
>
|
||||
<DialogContent className="max-w-5xl w-full h-[80vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/50 shrink-0">
|
||||
<DialogTitle className="text-sm font-mono font-medium truncate">
|
||||
{filePath ?? "Diff Viewer"}{" "}
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
(hunks-only preview — shows changed regions, not full file)
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 min-h-0">
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language={language ?? "plaintext"}
|
||||
modified={modified}
|
||||
options={{
|
||||
readOnly: true,
|
||||
renderSideBySide: true,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 13,
|
||||
}}
|
||||
original={original}
|
||||
theme="vs-dark"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,15 @@ import { ArrowDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
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 { Separator } from "@/shared/ui/separator";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { DiffMessage } from "./DiffMessage";
|
||||
|
||||
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
|
||||
|
||||
type MessageTimelineProps = {
|
||||
messages: TimelineMessage[];
|
||||
@@ -28,6 +32,9 @@ function isNearBottom(container: HTMLDivElement) {
|
||||
|
||||
function MessageRow({ message }: { message: TimelineMessage }) {
|
||||
const [hasAvatarError, setHasAvatarError] = React.useState(false);
|
||||
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const initials = message.author
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
@@ -35,6 +42,30 @@ function MessageRow({ message }: { message: TimelineMessage }) {
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
const getTag = (name: string) =>
|
||||
message.tags?.find((t) => t[0] === name)?.[1];
|
||||
|
||||
const renderBody = () => {
|
||||
switch (message.kind) {
|
||||
case KIND_STREAM_MESSAGE_DIFF:
|
||||
return (
|
||||
<DiffMessage
|
||||
commitSha={getTag("commit")}
|
||||
content={message.body}
|
||||
description={getTag("description")}
|
||||
filePath={getTag("file")}
|
||||
onExpand={() => {
|
||||
setExpandedDiffId(message.id);
|
||||
}}
|
||||
repoUrl={getTag("repo")}
|
||||
truncated={getTag("truncated") === "true"}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Markdown className="max-w-3xl" content={message.body} tight />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
@@ -88,7 +119,25 @@ function MessageRow({ message }: { message: TimelineMessage }) {
|
||||
<p className="whitespace-nowrap">{message.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Markdown className="max-w-3xl" content={message.body} tight />
|
||||
{renderBody()}
|
||||
{expandedDiffId === message.id && (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
Loading diff viewer…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DiffMessageExpanded
|
||||
content={message.body}
|
||||
filePath={getTag("file")}
|
||||
language={getTag("l")}
|
||||
onClose={() => {
|
||||
setExpandedDiffId(null);
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
signRelayEvent,
|
||||
} from "@/shared/api/tauri";
|
||||
import type { PresenceStatus, RelayEvent } from "@/shared/api/types";
|
||||
import { STREAM_MESSAGE_KINDS } from "@/shared/constants/kinds";
|
||||
|
||||
type RelaySubscriptionFilter = {
|
||||
kinds: number[];
|
||||
@@ -230,7 +231,7 @@ class RelayClient {
|
||||
limit: number,
|
||||
): RelaySubscriptionFilter {
|
||||
return {
|
||||
kinds: [40001],
|
||||
kinds: [...STREAM_MESSAGE_KINDS],
|
||||
"#h": [channelId],
|
||||
limit,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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_STREAM_MESSAGE, // 40001 — was in original filter
|
||||
KIND_STREAM_MESSAGE_DIFF, // 40008 — new in this PR
|
||||
] as const;
|
||||
Reference in New Issue
Block a user