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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 96 additions & 23 deletions src/html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,89 @@ fn author_handle_html(profile: Option<&ProfileRecord<'_>>) -> String {
.unwrap_or_default()
}

/// Render a note preview as plain HTML text with mention resolution.
///
/// Iterates content blocks, resolving nprofile/npub mentions to `@DisplayName`
/// and hashtags to `#tag`. Truncates to `max_chars` on block boundaries
/// (never slices mid-mention) and appends "..." when truncated.
pub fn render_note_preview_html(
note: &Note,
ndb: &Ndb,
txn: &Transaction,
max_chars: usize,
) -> String {
let blocks = note
.key()
.and_then(|nk| ndb.get_blocks_by_key(txn, nk).ok());

let Some(blocks) = blocks else {
let content = abbreviate(note.content(), max_chars);
let truncated = content.len() < note.content().len();
let escaped = html_escape::encode_text(content);
if truncated {
return format!("{}...", escaped);
}
return escaped.into_owned();
};

let mut out = String::new();
let mut char_count = 0;
let mut truncated = false;

for block in blocks.iter(note) {
let fragment = match block.blocktype() {
BlockType::MentionBech32 => {
if let Some(mention) = block.as_mention() {
match mention {
Mention::Profile(nprofile) => {
resolve_mention_name(ndb, txn, nprofile.pubkey(), block.as_str())
}
Mention::Pubkey(npub) => {
resolve_mention_name(ndb, txn, npub.pubkey(), block.as_str())
}
_ => format!("@{}", abbrev_str(block.as_str())),
}
} else {
format!("@{}", abbrev_str(block.as_str()))
}
}
BlockType::Hashtag => format!("#{}", block.as_str()),
_ => block.as_str().to_owned(),
};

let fragment_chars = fragment.chars().count();
if char_count + fragment_chars > max_chars {
// For plain text blocks, include a truncated portion instead of
// dropping the entire block.
let remaining = max_chars - char_count;
if remaining > 0 && matches!(block.blocktype(), BlockType::Text) {
let partial: String = fragment.chars().take(remaining).collect();
let _ = write!(out, "{}", html_escape::encode_text(&partial));
}
truncated = true;
break;
}

let _ = write!(out, "{}", html_escape::encode_text(&fragment));
char_count += fragment_chars;
}

if truncated {
out.push_str("...");
}

out
}

/// Resolve a mention pubkey to `@DisplayName` or fall back to abbreviated bech32.
fn resolve_mention_name(ndb: &Ndb, txn: &Transaction, pk: &[u8; 32], raw: &str) -> String {
let record = ndb.get_profile_by_pubkey(txn, pk).ok();
match get_profile_display_name(record.as_ref()) {
Some(name) => format!("@{}", name),
None => format!("@{}", abbrev_str(raw)),
}
}

/// Extracts parent note info for thread layout.
/// Returns None if the note is not a reply.
struct ParentNoteInfo {
Expand All @@ -1079,7 +1162,13 @@ fn get_parent_note_info(
let reply_info = NoteReply::new(note.tags());
let parent_ref = reply_info.reply().or_else(|| reply_info.root())?;

let link = EventId::from_byte_array(*parent_ref.id)
// Use nevent1 (with relay hint) instead of note1 for better discoverability
let event_id = EventId::from_byte_array(*parent_ref.id);
let mut nevent = Nip19Event::new(event_id);
if let Some(relay) = parent_ref.relay.and_then(|s| RelayUrl::parse(s).ok()) {
nevent = nevent.relays(std::iter::once(relay));
}
let link = nevent
.to_bech32()
.map(|b| format!("{}/{}", base_url, b))
.unwrap_or_else(|_| "#".to_string());
Expand All @@ -1089,13 +1178,6 @@ fn get_parent_note_info(
let parent_profile = ndb.get_profile_by_pubkey(txn, parent_note.pubkey()).ok();
let name = get_profile_display_name(parent_profile.as_ref()).unwrap_or("nostrich");

let content = abbreviate(parent_note.content(), 200);
let ellipsis = if content.len() < parent_note.content().len() {
"..."
} else {
""
};

let pfp = pfp_url_attr(
parent_profile.as_ref().and_then(|r| r.record().profile()),
base_url,
Expand All @@ -1109,7 +1191,7 @@ fn get_parent_note_info(
parent_note.created_at(),
))
.into_owned(),
content_html: format!("{}{}", html_escape::encode_text(content), ellipsis),
content_html: render_note_preview_html(&parent_note, ndb, txn, 200),

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

These preview call sites still miss the prefetch contract on profile pages.

Line 1194 and Line 1352 now depend on render_note_preview_html, which can only resolve names that are already in NDB. The note route satisfies that in src/main.rs (Line 412-Line 455), but serve_profile_html renders recent notes through build_note_content_html in this file (Line 2169-Line 2176) without an equivalent fetch stage. Recent notes on profile pages can therefore still fall back to abbreviated bech32 in parent/reply previews instead of resolved display names. Please mirror that prefetch/readiness step there, ideally batched across the recent-note query.

Also applies to: 1352-1352

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/html.rs` at line 1194, The profile page is rendering recent notes via
build_note_content_html and calling render_note_preview_html without performing
the name-prefetch/readiness that the note route does, so parent/reply previews
may show bech32 instead of resolved display names; update serve_profile_html
(the handler that builds recent-note HTML) to mirror the note-route prefetch
step: after fetching the recent notes list, collect all author/prefetch keys
from those notes (including parents/replies), perform the same batched ndb
fetch/readiness call used by the note route, await its completion, then call
build_note_content_html/render_note_preview_html so names are resolved; ensure
you reference the same readiness/fetch helper used by the note route to keep
behavior consistent and batch across the recent-note query to avoid N+1 fetches.

})
}
Err(_) => {
Expand Down Expand Up @@ -1259,24 +1341,15 @@ fn build_replies_html(app: &Notecrumbs, txn: &Transaction, note: &Note, base_url
let display_name = get_profile_display_name(profile_rec.as_ref()).unwrap_or("nostrich");
let display_name_html = html_escape::encode_text(display_name);

let pfp_url = profile_rec
.as_ref()
.and_then(|r| r.record().profile())
.and_then(|p| p.picture())
.filter(|s| !s.is_empty())
.unwrap_or("/img/no-profile.svg");
let pfp_attr = html_escape::encode_double_quoted_attribute(pfp_url);
let pfp_attr = pfp_url_attr(
profile_rec.as_ref().and_then(|r| r.record().profile()),
base_url,
);

let time_str = format_relative_time(reply.created_at());
let time_html = html_escape::encode_text(&time_str);

let content = abbreviate(reply.content(), 300);
let ellipsis = if content.len() < reply.content().len() {
"..."
} else {
""
};
let content_html = format!("{}{}", html_escape::encode_text(content), ellipsis);
let content_html = render_note_preview_html(reply, &app.ndb, txn, 300);

let reply_nevent = Nip19Event::new(EventId::from_byte_array(reply.id().to_owned()));
let reply_id = reply_nevent.to_bech32().unwrap_or_default();
Expand Down
22 changes: 22 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,17 @@ async fn fetch_note_secondary_data(
render::fetch_unknowns(relay_pool, ndb, unknowns).await?;
}

// Fetch parent note content unknowns (profiles mentioned in parent)
if let Some(parent_unknowns) = render::collect_parent_unknowns(ndb, note_rd) {
tracing::debug!(
"fetching {} parent content unknowns",
parent_unknowns.ids_len()
);
if let Err(err) = render::fetch_unknowns(relay_pool, ndb, parent_unknowns).await {
tracing::warn!("failed to fetch parent unknowns: {err}");
}
}

// Fetch note stats (reactions, replies, reposts)
render::fetch_note_stats(relay_pool, ndb, note_rd, source_relays).await?;

Expand All @@ -429,6 +440,17 @@ async fn fetch_note_secondary_data(
}
}

// Fetch profiles mentioned in reply content
if let Some(reply_content_unknowns) = render::collect_reply_content_unknowns(ndb, note_rd) {
tracing::debug!(
"fetching {} reply content unknowns",
reply_content_unknowns.ids_len()
);
if let Err(err) = render::fetch_unknowns(relay_pool, ndb, reply_content_unknowns).await {
tracing::warn!("failed to fetch reply content unknowns: {err}");
}
}

Ok(())
}

Expand Down
Loading
Loading