Skip to content

feat: render NIP-84 highlights, NIP-18 quotes with rich embeds#51

Closed
alltheseas wants to merge 7 commits intodamus-io:masterfrom
alltheseas:master
Closed

feat: render NIP-84 highlights, NIP-18 quotes with rich embeds#51
alltheseas wants to merge 7 commits intodamus-io:masterfrom
alltheseas:master

Conversation

@alltheseas
Copy link
Copy Markdown
Contributor

@alltheseas alltheseas commented Dec 19, 2025

Summary

  • NIP-84 Highlights (kind:9802): Render highlighted text with blockquote styling, context, and source attribution (web URLs, notes, articles)
  • NIP-18 Quote Embeds: Rich embedded quotes with author avatar, name, @username, relative timestamps, and "Show more" for truncated content
  • NIP-23 Draft Badge: Visual indicator for unpublished articles (kind:30024)
  • NIP-10 Reply Detection: Detect reply-to author via e-tag "reply" marker
  • Human-readable mentions: Resolve @npub mentions to display names

Test plan

  • Test NIP-84 highlight rendering with various source types (web URL, note, article)
  • Test quote embeds display author avatar, name, username correctly
  • Test relative timestamps show correctly (e.g., "7h", "2d")
  • Test draft badge appears for kind:30024 articles
  • Test profile mentions resolve to display names

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added support for embedded quotes and article previews displayed within notes
    • Implemented NIP-84 highlight rendering with full source attribution and metadata support
    • Improved relay selection for better link generation and content discovery
  • Style

    • Expanded CSS styling for article drafts, highlights, quoted content, and responsive layouts

✏️ Tip: You can customize this high-level summary in your review settings.

alltheseas and others added 2 commits December 18, 2025 21:23
Adds comprehensive rendering for additional nostr event types:

NIP-84 Highlights (kind:9802):
- Extract highlight metadata (context, comment, source references)
- Render highlighted text with blockquote styling
- Support source attribution for web URLs, notes, and articles
- Preserve relay hints via bech32 parsing for nevent/naddr

NIP-18 Quote Embeds (q tags and inline mentions):
- Parse q tags and inline nostr:nevent/note mentions for quote references
- Rich embedded quotes with author avatar, name, @username
- Relative timestamps (e.g., "7h", "2d") with full date on hover
- "Show more" link for truncated content
- NIP-10 reply detection via e-tag "reply" marker

NIP-23 Draft Badge:
- Visual indicator for unpublished articles (kind:30024)

Human-readable mentions:
- Resolve @npub profile mentions to display names when available
- Show abbreviated bech32 as fallback when profile not cached

Helper functions for clean code organization:
- parse_hex_id(): hex string to 32-byte array
- detect_reply_author(): NIP-10 compliant reply detection
- lookup_profile_handle(): profile name resolution

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Fixes 404 errors when Ubuntu package versions change on mirrors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@alltheseas
Copy link
Copy Markdown
Contributor Author

  1. quote note + npub mention resolving to human readable name
Screenshot 2025-12-18 at 9 38 57 PM

- Add @username handle below author name in note/article/highlight headers
- Fix profile name lookup to filter out empty strings (prevents showing
  just "@" when profile has empty display_name/name fields)
- Add CSS styling for .damus-note-handle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@alltheseas
Copy link
Copy Markdown
Contributor Author

alltheseas commented Dec 19, 2025

draft draft tag for kind-30024 (drafts of longform articles) Screenshot 2025-12-18 at 9 51 21 PM

- Show "Article" badge for kind 30023 embedded quotes
- Show "Draft" badge (orange) for kind 30024 embedded quotes
- Show "Highlight" badge for kind 9802 embedded quotes
- Fix empty string filtering in embedded quote profile extraction

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@alltheseas
Copy link
Copy Markdown
Contributor Author

alltheseas commented Dec 19, 2025

article highlight from website & quote
Screenshot 2025-12-18 at 9 56 34 PM

@alltheseas
Copy link
Copy Markdown
Contributor Author

highlight
Screenshot 2025-12-18 at 10 03 34 PM

quote highlight
Screenshot 2025-12-18 at 10 05 00 PM

- Add support for highlight (kind 9802) references in naddr mentions
- Style embedded highlights with left purple border instead of tag
- Remove quotation marks from highlight content
- Filter empty strings in embedded quote profile extraction

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@alltheseas
Copy link
Copy Markdown
Contributor Author

updated longform quote
image

alltheseas and others added 2 commits December 18, 2025 22:26
Embedded article quotes now display as cards matching iOS Damus:
- Hero image (if available)
- Bold article title
- Summary text (if available)
- Word count
- DRAFT badge via CSS for kind 30024

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Integrate relay provenance tracking from rust-nostr PR #1172 to enable
proper NIP-19 bech32 links with relay hints for better content discoverability.

Changes:
- Update nostr-sdk to alltheseas/rust-nostr relay-provenance-tracking branch
- RelayPool::stream_events returns BoxedStream<RelayEvent> with source relay URL
- NoteAndProfileRenderData stores source_relays captured during fetch
- Generate bech32 links with relay hints for all event types (notes, articles, highlights)
- Filter profile (kind 0) relays from note hints
- Prioritize default relays in source_relays for reliability
- Preserve author/kind fields when rebuilding nevent bech32
- Graceful fallback to original nip19 on encoding failure with metric
- Add bech32_with_relays() helper with 8 unit tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@alltheseas
Copy link
Copy Markdown
Contributor Author

@coderabbitai review please

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Jan 16, 2026

@alltheseas: I'll review the changes in this PR for you.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Jan 16, 2026

📝 Walkthrough

Walkthrough

This pull request modifies a Nostr relay client to support relay source tracking, enhanced HTML rendering for NIP-84 highlights and embedded quotes with database context, CSS styling for new UI components, and refactored relay pool APIs. Dependencies are updated to Git-based versions for relay provenance tracking.

Changes

Cohort / File(s) Summary
CI/CD Workflow
.github/workflows/rust.yml
Updated Deps step to include explicit apt-get update and -y flag for non-interactive installation of libfontconfig1-dev, libfreetype6-dev, libssl-dev.
Dependency Management
Cargo.toml
Replaced published crate versions for nostr-sdk and nostr (0.37.0) with Git-based dependencies pointing to relay-provenance-tracking branch of alltheseas/rust-nostr repository.
Styling
assets/damus.css
Added 217 new CSS utility and component classes for highlights (damus-highlight-\), embedded quotes (damus-embedded-quote-\), article embeds (damus-embedded-article-\*), drafts, and responsive layout refinements.
HTML Rendering
src/html.rs
Significantly expanded rendering pipeline: updated render_note_content() signature to include ndb and txn parameters for database-backed lookups; added support for NIP-84 highlights (kind 9802), embedded quotes, and articles (kinds 30023/30024) with rich metadata extraction; introduced helpers for highlight metadata, quote references, article info, source attribution, and relative time formatting.
Relay Pool & Networking
src/relay_pool.rs, src/main.rs
Removed connect_timeout parameter from RelayPool::new(); changed stream_events() to accept single Filter and return BoxedStream<RelayEvent> instead of ReceiverStream<Event>; updated relay connection logic to use default timeout behavior.
NIP-19 Encoding
src/nip19.rs
Added public function bech32_with_relays() to construct bech32 strings with relay hints; includes comprehensive tests for relay replacement, empty relay handling, and author/kind preservation.
Render Data & Source Tracking
src/render.rs
Added source_relays: Vec<RelayUrl> field to NoteAndProfileRenderData with accessors; changed find_note() return type from Result<()> to Result<Vec<RelayUrl>> to track source relay URLs with deduplication and prioritization; refactored relay-fetching loops and conversions to propagate source relays through rendering pipeline.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 With database and relays bright,
Highlights dance in HTML light,
Quotes embedded, sources trace,
Bech32 links find their place!
Relay provenance takes flight! 🚀

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the primary changes: rendering NIP-84 highlights and NIP-18 quotes with rich embedded content, which is the main feature across the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@Cargo.toml`:
- Around line 20-24: The Cargo.toml currently depends on branch names for
nostr-sdk and nostr which is non-deterministic; change both entries to use
explicit git commit hashes instead (replace the branch =
"relay-provenance-tracking" with rev = "<commit-hash>") for the nostr-sdk and
nostr dependencies in Cargo.toml, then run cargo update -p nostr-sdk -p nostr
(or cargo build) to regenerate and commit the updated Cargo.lock so the exact
resolved revisions are recorded for reproducible builds.

In `@src/html.rs`:
- Around line 831-1048: The loop in build_embedded_quotes_html currently
continues when quoted_note is None, dropping references; instead emit a minimal
fallback card linking to build_quote_link(quote_ref) so missing events still
render a placeholder link. Modify the for loop where quoted_note is computed:
when None, call build_quote_link(quote_ref) and append a small HTML snippet
(e.g. an <a> with class "damus-embedded-quote" containing a placeholder avatar,
author/name like "nostrich", a time placeholder or empty, and a short
"Referenced event" label) using the same write! pattern that appends to
quotes_html; keep all existing behavior for cases where quoted_note is present.
Ensure the fallback uses the same content_class or a distinct fallback class to
allow styling.
🧹 Nitpick comments (2)
src/render.rs (1)

71-96: Keep source relay dedup consistent in the builder helper.

with_source_relay bypasses the duplicate guard used by add_source_relay, so chained calls can emit duplicate relay hints. Consider reusing the same check.

♻️ Suggested tweak
-    pub fn with_source_relay(mut self, relay: RelayUrl) -> Self {
-        self.source_relays.push(relay);
-        self
-    }
+    pub fn with_source_relay(mut self, relay: RelayUrl) -> Self {
+        if !self.source_relays.contains(&relay) {
+            self.source_relays.push(relay);
+        }
+        self
+    }
src/html.rs (1)

538-587: Consider adding defensive fallback for markers when relay URLs are omitted.

While NIP-10 specifies markers at index 3, real-world Nostr implementations may omit empty relay URLs entirely, shifting markers to index 2. The suggested fix adds a safe fallback that validates the value matches known marker types, preventing false positives:

Suggested improvement
-        let tag_marker = tag_vec.get(3).and_then(|n| n.variant().str());
+        let tag_marker = tag_vec
+            .get(3)
+            .and_then(|n| n.variant().str())
+            .or_else(|| {
+                tag_vec
+                    .get(2)
+                    .and_then(|n| n.variant().str())
+                    .filter(|value| matches!(*value, "root" | "reply" | "mention"))
+            });
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ad9d797 and 4229342.

⛔ Files ignored due to path filters (14)
  • .DS_Store is excluded by !**/.DS_Store
  • Cargo.lock is excluded by !**/*.lock
  • PM.png is excluded by !**/*.png
  • QL.png is excluded by !**/*.png
  • borked.png is excluded by !**/*.png
  • brokenlongformquote.png is excluded by !**/*.png
  • brokenquote.png is excluded by !**/*.png
  • draft.png is excluded by !**/*.png
  • iosquote.jpg is excluded by !**/*.jpg
  • missingname.png is excluded by !**/*.png
  • missingqh.png is excluded by !**/*.png
  • missingqh2.png is excluded by !**/*.png
  • npubnotname.png is excluded by !**/*.png
  • quote1.png is excluded by !**/*.png
📒 Files selected for processing (8)
  • .github/workflows/rust.yml
  • Cargo.toml
  • assets/damus.css
  • src/html.rs
  • src/main.rs
  • src/nip19.rs
  • src/relay_pool.rs
  • src/render.rs
💤 Files with no reviewable changes (1)
  • src/main.rs
🧰 Additional context used
🧬 Code graph analysis (3)
src/nip19.rs (2)
src/relay_pool.rs (1)
  • new (28-53)
src/render.rs (1)
  • new (80-86)
src/render.rs (1)
src/relay_pool.rs (2)
  • new (28-53)
  • default_relays (55-57)
src/html.rs (2)
src/abbrev.rs (2)
  • abbrev_str (24-31)
  • abbreviate (33-36)
src/nip19.rs (2)
  • bech32_with_relays (19-46)
  • nip19_relays (6-13)
🔇 Additional comments (28)
.github/workflows/rust.yml (1)

18-21: LGTM. The apt update step improves package install reliability in CI.

assets/damus.css (3)

261-266: LGTM. Handle styling is consistent with existing note typography.


358-408: LGTM. Draft badge and highlight styles look consistent with the theme.


503-659: LGTM. Embedded quote and article card styles are cohesive and readable.

src/nip19.rs (2)

1-45: LGTM. Relay override logic is clear and preserves author/kind with a safe fallback.


48-208: LGTM. Tests cover add/replace/empty relay cases and metadata preservation.

src/relay_pool.rs (3)

28-53: LGTM. Constructor aligns with the simplified relay pool API.


144-162: LGTM. The empty‑relays vs explicit‑relays branching is clean and predictable.


106-108: Remove this comment — Client::connect() returns unit, not Result.

The method signature is pub async fn connect(&self), which returns (), not a Result type. There are no errors to handle or drop, making the error handling concern invalid. The suggested fix would not compile against the current nostr-sdk implementation.

Likely an incorrect or invalid review comment.

src/render.rs (6)

328-398: Relay source collection and prioritization look solid.

The flow gathers responding relays, prefers defaults, and caps to three—nice balance between reliability and payload size.


566-575: Source relay propagation from the fetch task is clear.


664-691: Per-filter relay discovery loop is straightforward.


719-733: Relay hint application during profile feed fetch looks good.


742-842: Pubkey byte conversion updates look correct.

Please confirm these conversions align with the current nostr-sdk PublicKey::to_bytes() expectations.


1214-1218: Test updated for Tag::coordinate signature.

Please verify the tag API signature matches the current nostr crate.

src/html.rs (13)

10-11: Bech32 parsing import aligns with new helpers.

Please confirm the FromBech32 trait path matches the current nostr-sdk API.


80-101: Highlight metadata model is clear and well scoped.


177-293: Highlight tag parsing covers context/comment/source variants cleanly.


400-505: Mention rendering enhancements are clean.

Profile mention resolution and escaping look good.


507-535: Profile lookup helpers are tidy and reusable.


589-673: Relative time formatting and URL pill utility look good.


675-829: Quote reference extraction is thorough.


1050-1122: Article lookup and quote-link fallback look reasonable.


1143-1238: Note HTML now includes @handle and embedded quote cards.


1242-1326: Draft badge integration for 30024 articles is clear.


1329-1386: Highlight card composition is clean and readable.


1388-1637: Source attribution helpers are nicely layered.


2304-2436: Routing for articles/highlights and relay-aware canonical URLs looks good.

Please confirm the relay-augmented bech32 behavior aligns with current NIP-19/nostr-sdk expectations.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines +20 to +24
#nostr-sdk = "0.37.0"
#nostr = "0.37.0"
# PR #1172: relay provenance tracking - https://github.com/rust-nostr/nostr/pull/1172
nostr-sdk = { git = "https://github.com/alltheseas/rust-nostr.git", branch = "relay-provenance-tracking" }
nostr = { git = "https://github.com/alltheseas/rust-nostr.git", branch = "relay-provenance-tracking" }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Cargo.toml git dependencies branch vs commit hash reproducibility best practices

💡 Result:

Short answer — use rev (full commit SHA) or an annotated/tagged release for reproducibility; avoid depending only on a branch.

Best-practices (concise):

  • Pin to a commit (rev = "") in Cargo.toml for maximal reproducibility — it fixes the exact tree. [1][2]
  • If you want human-readable stability, pin to a tag (tag = "v1.2.3") that you control/publish. [1][3]
  • Do NOT rely on branch = "…" for reproducible builds (branches move; Cargo only locks the commit at the time you add it, so later pulls/CI might differ unless you pin). Use branch only for tracking a moving target intentionally. [1]
  • Always check-in Cargo.lock for applications (ensures everyone uses the same git commit resolved at add-time). Cargo.lock records the git rev Cargo used. [1][2]
  • For library crates published to crates.io: avoid git deps (crates.io disallows publishing crates with external git deps). Use registry releases or vendor/patch instead. [1]
  • Use [patch.crates-io] / workspace overrides to override indirect deps or point to a specific git rev when needed. [2]
  • When pinning by rev, prefer the full SHA (not a short prefix) and update intentionally (cargo update or change rev). Keep notes/CHANGELOG for why you pinned. [3]

Sources: Cargo reference (specifying git deps, role of rev/branch/tag and Cargo.lock). [1] Stack Overflow (pinning to commit + [patch] override examples). [2] Practical guide on pinning to commit/tag. [3]

References
[1] Rust Cargo book — Specifying dependencies (git/branch/tag/rev, Cargo.lock).
[2] Stack Overflow — How to specify a certain commit / pin indirect deps (rev, [patch]).
[3] Guide — Pinning dependencies to specific git revisions in Cargo.


🏁 Script executed:

# Check if the branch exists and get its current state
# First, let's try to fetch information about the remote repository
git ls-remote https://github.com/alltheseas/rust-nostr.git relay-provenance-tracking 2>/dev/null || echo "Could not fetch remote refs"

Repository: damus-io/notecrumbs

Length of output: 140


🌐 Web query:

rust-nostr relay provenance tracking feature upstream release

💡 Result:

I couldn't find any mention of a "provenance tracking" feature being released upstream in the rust‑nostr relay codebase or release notes. The rust‑nostr repository and recent release changelogs (e.g. v0.36–v0.41) list many relay and relay‑pool improvements but do not mention provenance tracking. [1][2]

If you want, I can:

  • search open issues/PRs for a provenance-related discussion in the rust‑nostr org, or
  • look for provenance-tracking implementations in other Rust relays (rnostr, nostr-rs-relay, gn…etc.) and report back.

Which would you prefer?

Sources:

  1. rust‑nostr GitHub repo (projects, supported NIPs, releases). [1]
  2. rust‑nostr release summaries (v0.36–v0.41). [2]

Pin git dependencies to a commit for reproducible builds.

Using a branch makes builds non-deterministic and increases supply-chain risk. Pin to a specific commit hash and ensure Cargo.lock is committed to lock the exact resolved revision.

🔧 Suggested change
-nostr-sdk = { git = "https://github.com/alltheseas/rust-nostr.git", branch = "relay-provenance-tracking" }
-nostr = { git = "https://github.com/alltheseas/rust-nostr.git", branch = "relay-provenance-tracking" }
+nostr-sdk = { git = "https://github.com/alltheseas/rust-nostr.git", rev = "2ea3dbea4d89697e1c2f2bd4dea1bb68432df6f5" }
+nostr = { git = "https://github.com/alltheseas/rust-nostr.git", rev = "2ea3dbea4d89697e1c2f2bd4dea1bb68432df6f5" }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#nostr-sdk = "0.37.0"
#nostr = "0.37.0"
# PR #1172: relay provenance tracking - https://github.com/rust-nostr/nostr/pull/1172
nostr-sdk = { git = "https://github.com/alltheseas/rust-nostr.git", branch = "relay-provenance-tracking" }
nostr = { git = "https://github.com/alltheseas/rust-nostr.git", branch = "relay-provenance-tracking" }
`#nostr-sdk` = "0.37.0"
`#nostr` = "0.37.0"
# PR `#1172`: relay provenance tracking - https://github.com/rust-nostr/nostr/pull/1172
nostr-sdk = { git = "https://github.com/alltheseas/rust-nostr.git", rev = "2ea3dbea4d89697e1c2f2bd4dea1bb68432df6f5" }
nostr = { git = "https://github.com/alltheseas/rust-nostr.git", rev = "2ea3dbea4d89697e1c2f2bd4dea1bb68432df6f5" }
🤖 Prompt for AI Agents
In `@Cargo.toml` around lines 20 - 24, The Cargo.toml currently depends on branch
names for nostr-sdk and nostr which is non-deterministic; change both entries to
use explicit git commit hashes instead (replace the branch =
"relay-provenance-tracking" with rev = "<commit-hash>") for the nostr-sdk and
nostr dependencies in Cargo.toml, then run cargo update -p nostr-sdk -p nostr
(or cargo build) to regenerate and commit the updated Cargo.lock so the exact
resolved revisions are recorded for reproducible builds.

Comment on lines +831 to +1048
/// Builds embedded quote HTML for referenced events.
/// Returns empty string if no quotes or quoted events not found.
fn build_embedded_quotes_html(
ndb: &Ndb,
txn: &Transaction,
quote_refs: &[QuoteRef],
) -> String {
if quote_refs.is_empty() {
return String::new();
}

let mut quotes_html = String::new();

for quote_ref in quote_refs {
// Try to find the quoted note in nostrdb
let quoted_note = if let Some(event_id) = &quote_ref.event_id {
// Look up by event ID
ndb.get_note_by_id(txn, event_id).ok()
} else if let Some(addr) = &quote_ref.article_addr {
// Look up article by address
lookup_article_by_addr(ndb, txn, addr)
} else {
None
};

let Some(quoted_note) = quoted_note else {
continue;
};

// Get author profile for the quoted note (name, username, pfp)
// Filter out empty strings to ensure proper fallback behavior
let (display_name, username, pfp_url) = ndb
.get_profile_by_pubkey(txn, quoted_note.pubkey())
.ok()
.and_then(|profile_rec| {
profile_rec.record().profile().map(|p| {
let name = p.display_name()
.filter(|s| !s.is_empty())
.or_else(|| p.name().filter(|s| !s.is_empty()))
.map(|n| n.to_owned());
let handle = p.name()
.filter(|s| !s.is_empty())
.map(|n| format!("@{}", n));
let picture = p.picture()
.filter(|s| !s.is_empty())
.map(|s| s.to_owned());
(name, handle, picture)
})
})
.unwrap_or((None, None, None));

let display_name = display_name.unwrap_or_else(|| "nostrich".to_string());
let display_name_html = html_escape::encode_text(&display_name);
let username_html = username
.map(|u| format!(r#" <span class="damus-embedded-quote-username">{}</span>"#,
html_escape::encode_text(&u)))
.unwrap_or_default();

// Build profile picture HTML - use placeholder if not available
let pfp_html = pfp_url
.filter(|url| !url.trim().is_empty())
.map(|url| {
let pfp_attr = html_escape::encode_double_quoted_attribute(&url);
format!(r#"<img src="{}" class="damus-embedded-quote-avatar" alt="" />"#, pfp_attr)
})
.unwrap_or_else(|| {
r#"<img src="/img/no-profile.svg" class="damus-embedded-quote-avatar" alt="" />"#.to_string()
});

// Get relative timestamp
let timestamp = quoted_note.created_at();
let relative_time = format_relative_time(timestamp);
let time_html = html_escape::encode_text(&relative_time);

// Detect reply context per NIP-10:
// 1. Find e-tag with "reply" marker → look up that event → get author
// 2. Fallback: use first p-tag not marked "mention"
let reply_to = detect_reply_author(ndb, txn, &quoted_note);

let reply_html = reply_to
.map(|name| format!(
r#"<div class="damus-embedded-quote-reply">Replying to {}</div>"#,
html_escape::encode_text(&name)
))
.unwrap_or_default();

// Build content preview, type indicator, and content class based on note kind
// For articles, we use a special card layout with image, title, summary, word count
let (content_preview, is_truncated, type_indicator, content_class, article_card) = match quoted_note.kind() {
// For articles, extract metadata and build card layout
30023 | 30024 => {
let mut title: Option<&str> = None;
let mut image: Option<&str> = None;
let mut summary: Option<&str> = None;

for tag in quoted_note.tags() {
let mut iter = tag.into_iter();
let Some(tag_name) = iter.next().and_then(|n| n.variant().str()) else {
continue;
};
let tag_value = iter.next().and_then(|n| n.variant().str());
match tag_name {
"title" => title = tag_value,
"image" => image = tag_value.filter(|s| !s.is_empty()),
"summary" => summary = tag_value.filter(|s| !s.is_empty()),
_ => {}
}
}

// Calculate word count
let word_count = quoted_note.content().split_whitespace().count();
let word_count_text = format!("{} Words", word_count);

// Build article card HTML
let title_text = title.unwrap_or("Untitled article");
let title_html = html_escape::encode_text(title_text);

let image_html = image
.map(|url| {
let url_attr = html_escape::encode_double_quoted_attribute(url);
format!(r#"<img src="{}" class="damus-embedded-article-image" alt="" />"#, url_attr)
})
.unwrap_or_default();

let summary_html = summary
.map(|s| {
let text = html_escape::encode_text(abbreviate(s, 150));
format!(r#"<div class="damus-embedded-article-summary">{}</div>"#, text)
})
.unwrap_or_default();

let draft_class = if quoted_note.kind() == 30024 { " damus-embedded-article-draft" } else { "" };

let card_html = format!(
r#"{image}<div class="damus-embedded-article-title{draft}">{title}</div>{summary}<div class="damus-embedded-article-wordcount">{words}</div>"#,
image = image_html,
draft = draft_class,
title = title_html,
summary = summary_html,
words = word_count_text
);

(String::new(), false, "", " damus-embedded-quote-article", Some(card_html))
}
// For highlights, show the highlighted text with left border styling (no tag needed)
9802 => {
let full_content = quoted_note.content();
let content = abbreviate(full_content, 200);
let truncated = content.len() < full_content.len();
(content.to_string(), truncated, "", " damus-embedded-quote-highlight", None)
}
// For regular notes, show abbreviated content
_ => {
let full_content = quoted_note.content();
let content = abbreviate(full_content, 280);
let truncated = content.len() < full_content.len();
(content.to_string(), truncated, "", "", None)
}
};
let content_html = html_escape::encode_text(&content_preview).replace("\n", " ");

// Build "Show more" link if content was truncated
let show_more_html = if is_truncated {
r#"<span class="damus-embedded-quote-showmore">Show more</span>"#
} else {
""
};

// Build link to quoted note
let link = build_quote_link(quote_ref);

// For articles, use card layout; for other types, use regular content layout
let body_html = if let Some(card) = article_card {
card
} else {
let show_more = if is_truncated {
r#" <span class="damus-embedded-quote-showmore">Show more</span>"#
} else {
""
};
format!(
r#"<div class="damus-embedded-quote-content{class}">{content}{showmore}</div>"#,
class = content_class,
content = content_html,
showmore = show_more
)
};

let _ = write!(
quotes_html,
r#"<a href="{link}" class="damus-embedded-quote{content_class}">
<div class="damus-embedded-quote-header">
{pfp}
<span class="damus-embedded-quote-author">{name}</span>{username}
<span class="damus-embedded-quote-time">· {time}</span>
{type_indicator}
</div>
{reply}
{body}
</a>"#,
link = link,
content_class = content_class,
pfp = pfp_html,
name = display_name_html,
username = username_html,
time = time_html,
type_indicator = type_indicator,
reply = reply_html,
body = body_html
);
}

if quotes_html.is_empty() {
return String::new();
}

format!(r#"<div class="damus-embedded-quotes">{}</div>"#, quotes_html)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Provide a fallback when quoted events aren’t found.

If the quoted note isn’t in nostrdb, the loop skips it; combined with suppressed inline mention rendering, this drops the reference entirely. Consider emitting a minimal link/card for missing quotes.

🐛 Suggested fallback
-        let Some(quoted_note) = quoted_note else {
-            continue;
-        };
+        let Some(quoted_note) = quoted_note else {
+            let link = build_quote_link(quote_ref);
+            let label = quote_ref
+                .original_bech32
+                .as_deref()
+                .map(abbrev_str)
+                .unwrap_or_else(|| "Quoted note".to_string());
+            let label_html = html_escape::encode_text(&label);
+            let _ = write!(
+                quotes_html,
+                r#"<a href="{link}" class="damus-embedded-quote damus-embedded-quote--missing">{label}</a>"#,
+                link = link,
+                label = label_html
+            );
+            continue;
+        };
🤖 Prompt for AI Agents
In `@src/html.rs` around lines 831 - 1048, The loop in build_embedded_quotes_html
currently continues when quoted_note is None, dropping references; instead emit
a minimal fallback card linking to build_quote_link(quote_ref) so missing events
still render a placeholder link. Modify the for loop where quoted_note is
computed: when None, call build_quote_link(quote_ref) and append a small HTML
snippet (e.g. an <a> with class "damus-embedded-quote" containing a placeholder
avatar, author/name like "nostrich", a time placeholder or empty, and a short
"Referenced event" label) using the same write! pattern that appends to
quotes_html; keep all existing behavior for cases where quoted_note is present.
Ensure the fallback uses the same content_class or a distinct fallback class to
allow styling.

@jb55
Copy link
Copy Markdown
Contributor

jb55 commented Feb 16, 2026

Closing — this work has already been incorporated into master via squash-merged commits.

@jb55 jb55 closed this Feb 16, 2026
jb55 added a commit to alltheseas/notecrumbs that referenced this pull request Feb 16, 2026
NIP-84 Highlights (kind:9802):
- Extract and render highlight metadata (context, comment)
- Source attribution for web URLs, notes, and articles
- Blockquote styling with left border accent

NIP-18 Embedded Quotes:
- Parse q tags and inline nevent/note/naddr mentions
- Rich quote cards with avatar, name, @handle, relative time
- Reply detection using nostrdb's NoteReply
- Type indicators for articles/highlights/drafts

Other improvements:
- Draft badge for unpublished articles (kind:30024)
- @username handles displayed under profile names
- Human-readable @mentions (resolve npub to display names)

Closes: damus-io#51
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: alltheseas <alltheseas@users.noreply.github.com>
jb55 added a commit that referenced this pull request Feb 16, 2026
* ci: add apt-get update before installing deps

Fixes 404 errors when Ubuntu package versions change on mirrors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* upgrade nostrdb to 0.9.0

Update to nostrdb 0.9.0 and fix breaking API changes:
- FilterBuilder.tags() now takes &str instead of String
- Handle new FilterField variants (Search, Relays, Custom)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add NIP-84 highlights and NIP-18 embedded quotes

NIP-84 Highlights (kind:9802):
- Extract and render highlight metadata (context, comment)
- Source attribution for web URLs, notes, and articles
- Blockquote styling with left border accent

NIP-18 Embedded Quotes:
- Parse q tags and inline nevent/note/naddr mentions
- Rich quote cards with avatar, name, @handle, relative time
- Reply detection using nostrdb's NoteReply
- Type indicators for articles/highlights/drafts

Other improvements:
- Draft badge for unpublished articles (kind:30024)
- @username handles displayed under profile names
- Human-readable @mentions (resolve npub to display names)

Closes: #51
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: alltheseas <alltheseas@users.noreply.github.com>

* feat: iOS-style article card for embedded longform quotes

Embedded article quotes now display as cards matching iOS Damus:
- Hero image (if available)
- Bold article title
- Summary text (if available)
- Word count
- DRAFT badge via CSS for kind 30024

Co-Authored-By: alltheseas <alltheseas@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: fetch quoted events from relays with relay provenance

Add an UnknownIds pattern (adapted from notedeck) to fetch quoted events
referenced in q tags and inline mentions. Events are fetched using relay
hints from nevent/naddr bech32 and q tag relay fields.

- Add src/unknowns.rs with UnknownId enum and UnknownIds collection
- Update QuoteRef to include relay hints (Vec<RelayUrl>)
- Extract relay hints from nevent/naddr bech32 and q tag third element
- Add collect_quote_unknowns() and fetch_unknowns() to render.rs
- Fetch quote unknowns in main.rs after primary note is loaded

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: generalize unknowns to fetch all missing data

Expand the unknowns pattern beyond just quoted events to collect:
- Author profiles
- Reply chain (root/reply) using nostrdb's NoteReply (NIP-10)
- Mentioned profiles (npub/nprofile with relay hints)
- Mentioned events (nevent/note1 with relay hints)
- Quoted events (q tags, inline mentions)

Move unknowns collection to main.rs for consistent handling
regardless of whether primary note was cached.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: fix unused argument warnings

Signed-off-by: William Casarin <jb55@jb55.com>

* fix: refresh profile metadata during background updates

Previously, background profile refreshes only fetched kind 1 (notes),
never updating kind 0 (profile metadata). This caused profiles to remain
stale indefinitely after initial cache.

Now fetch_profile_feed also fetches the latest profile metadata from
relays, allowing nostrdb to update cached profiles with newer versions.

Fixes: #52

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* rustfmt

Signed-off-by: William Casarin <jb55@jb55.com>

* refactor: replace tuple with QuoteProfileInfo struct in build_embedded_quotes_html

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: integrate rust-nostr PR #1172 relay provenance tracking

Integrate relay provenance tracking from rust-nostr PR #1172 to enable
proper NIP-19 bech32 links with relay hints for better content discoverability.

Changes:
- Update nostr-sdk to alltheseas/rust-nostr relay-provenance-tracking branch
- RelayPool::stream_events returns BoxedStream<RelayEvent> with source relay URL
- NoteAndProfileRenderData stores source_relays captured during fetch
- Generate bech32 links with relay hints for all event types (notes, articles, highlights)
- Filter profile (kind 0) relays from note hints
- Prioritize default relays in source_relays for reliability
- Preserve author/kind fields when rebuilding nevent bech32
- Graceful fallback to original nip19 on encoding failure with metric
- Add bech32_with_relays() helper with 8 unit tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: store relay provenance in nostrdb via IngestMetadata

Use process_event_with instead of process_event to pass the source
relay URL to nostrdb when ingesting events from relay streams.

Fixes: fffaa9e ("feat: integrate rust-nostr PR #1172 relay provenance tracking")
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add sitemap.xml and robots.txt for SEO

Add dynamic sitemap generation from nostrdb cache to improve search
engine discoverability of Nostr content.

New routes:
- GET /robots.txt - crawler directives with sitemap reference
- GET /sitemap.xml - dynamic sitemap from cached notes/profiles/articles

The sitemap queries local nostrdb for:
- Notes (kind:1) → note1xxx URLs
- Long-form articles (kind:30023) → naddr1xxx URLs
- Profiles (kind:0) → npub1xxx URLs

Ref: #26

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add prometheus metrics for sitemap generation

Track aggregate stats (privacy-preserving, no user tracking):
- sitemap_generations_total: counter for generation requests
- sitemap_generation_duration_seconds: time to generate
- sitemap_urls_total: total URLs in sitemap
- sitemap_notes_count: notes included
- sitemap_articles_count: articles included
- sitemap_profiles_count: profiles included

Metrics available at /metrics endpoint.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address sitemap code review findings

- Skip kind:30023 entries with missing/empty d-tag to avoid ambiguous
  URLs and potential collisions across authors
- Add since filter (90 days) to notes and articles queries to prioritize
  recent content for SEO freshness
- Log warning when NOTECRUMBS_BASE_URL is not set, to surface potential
  misconfiguration in production

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: cache base URL and use longer lookback for articles

- Cache base URL with OnceLock to avoid logging warning on every request
- Use separate lookback periods: 90 days for notes, 365 days for
  evergreen article content (kind:30023)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: normalize base url and allow nostr.json

* refactor: flatten nested conditionals with guard clauses

Use early returns and let-else patterns to reduce nesting depth in
generate_sitemap loops. Improves readability by making the happy path
linear instead of deeply indented.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Signed-off-by: William Casarin <jb55@jb55.com>
Co-authored-by: William Casarin <jb55@jb55.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: alltheseas <alltheseas@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants