diff --git a/src/imwald/core/database.py b/src/imwald/core/database.py
index d122cd2..8de4ba9 100644
--- a/src/imwald/core/database.py
+++ b/src/imwald/core/database.py
@@ -11,6 +11,9 @@ from typing import Any, Generator, Iterable
SCHEMA_VERSION = 2
+# Kind-1 text notes plus thread kinds that tag the root via ``e`` (show in feed thread column).
+THREAD_REPLY_KINDS: tuple[int, ...] = (1, 16, 1111, 1244)
+
DDL = """
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
@@ -443,16 +446,18 @@ class Database:
return out
def list_replies_to(self, event_id: str, limit: int = 80) -> list[dict[str, Any]]:
+ """Notes whose ``kind`` is in ``THREAD_REPLY_KINDS`` and tag this event (``e``); excludes reactions (7), etc."""
+ kind_ph = ",".join("?" * len(THREAD_REPLY_KINDS))
cur = self.conn().execute(
- """
+ f"""
SELECT e.id, e.pubkey, e.created_at, e.kind, e.content, e.sig, e.tags_json
FROM events e
JOIN tags t ON t.event_id = e.id AND t.name = 'e' AND t.value = ?
- WHERE e.deleted = 0
+ WHERE e.deleted = 0 AND e.kind IN ({kind_ph})
ORDER BY e.created_at ASC
LIMIT ?
""",
- (event_id, limit),
+ (event_id, *THREAD_REPLY_KINDS, limit),
)
return [
{
@@ -532,6 +537,114 @@ class Database:
row = cur.fetchone()
return int(row["vote"]) if row else None
+ def get_latest_kind0_profile(self, pubkey: str) -> dict[str, Any] | None:
+ """Latest non-deleted kind-0 for ``pubkey`` (hex), or None."""
+ cur = self.conn().execute(
+ """
+ SELECT content, created_at FROM events
+ WHERE deleted = 0 AND kind = 0 AND lower(pubkey) = lower(?)
+ ORDER BY created_at DESC LIMIT 1
+ """,
+ (pubkey,),
+ )
+ row = cur.fetchone()
+ if not row:
+ return None
+ return {"content": row["content"] or "", "created_at": int(row["created_at"])}
+
+ def get_latest_kind0_profiles(self, pubkeys: Iterable[str]) -> dict[str, dict[str, Any]]:
+ """Most recent kind-0 ``content`` per pubkey (lowercase hex keys)."""
+ pks = [p.lower() for p in pubkeys if isinstance(p, str) and len(p) == 64]
+ if not pks:
+ return {}
+ placeholders = ",".join("?" * len(pks))
+ cur = self.conn().execute(
+ f"""
+ SELECT x.pubkey AS pk, x.content, x.created_at
+ FROM (
+ SELECT lower(pubkey) AS pubkey, content, created_at,
+ ROW_NUMBER() OVER (PARTITION BY lower(pubkey) ORDER BY created_at DESC) AS rn
+ FROM events
+ WHERE deleted = 0 AND kind = 0 AND lower(pubkey) IN ({placeholders})
+ ) AS x
+ WHERE x.rn = 1
+ """,
+ pks,
+ )
+ out: dict[str, dict[str, Any]] = {}
+ for row in cur:
+ pk = str(row["pk"])
+ out[pk] = {"content": row["content"] or "", "created_at": int(row["created_at"])}
+ return out
+
+ def event_engagement_stats(self, event_id: str) -> dict[str, Any]:
+ """Counts from local DB: zaps (9735), reactions (7), boosts (6), quotes (``q`` on kind 1)."""
+ c = self.conn()
+ zaps = c.execute(
+ """
+ SELECT COUNT(*) AS n FROM events e
+ JOIN tags t ON t.event_id = e.id AND t.name = 'e' AND t.value = ?
+ WHERE e.deleted = 0 AND e.kind = 9735
+ """,
+ (event_id,),
+ ).fetchone()["n"]
+ boosts = c.execute(
+ """
+ SELECT COUNT(*) AS n FROM events e
+ JOIN tags t ON t.event_id = e.id AND t.name = 'e' AND t.value = ?
+ WHERE e.deleted = 0 AND e.kind = 6
+ """,
+ (event_id,),
+ ).fetchone()["n"]
+ quotes = c.execute(
+ """
+ SELECT COUNT(*) AS n FROM events e
+ JOIN tags t ON t.event_id = e.id AND t.name = 'q' AND t.value = ?
+ WHERE e.deleted = 0 AND e.kind = 1
+ """,
+ (event_id,),
+ ).fetchone()["n"]
+ reactions_total = c.execute(
+ """
+ SELECT COUNT(*) AS n FROM events e
+ JOIN tags t ON t.event_id = e.id AND t.name = 'e' AND t.value = ?
+ WHERE e.deleted = 0 AND e.kind = 7
+ """,
+ (event_id,),
+ ).fetchone()["n"]
+ cur_rx = c.execute(
+ """
+ SELECT COALESCE(NULLIF(TRIM(e.content), ''), '+') AS emoji, COUNT(*) AS c
+ FROM events e
+ JOIN tags t ON t.event_id = e.id AND t.name = 'e' AND t.value = ?
+ WHERE e.deleted = 0 AND e.kind = 7
+ GROUP BY 1
+ ORDER BY c DESC, emoji ASC
+ LIMIT 24
+ """,
+ (event_id,),
+ )
+ reaction_breakdown: list[tuple[str, int]] = [
+ (str(row["emoji"]), int(row["c"])) for row in cur_rx
+ ]
+ kind_ph = ",".join("?" * len(THREAD_REPLY_KINDS))
+ replies = c.execute(
+ f"""
+ SELECT COUNT(*) AS n FROM events e
+ JOIN tags t ON t.event_id = e.id AND t.name = 'e' AND t.value = ?
+ WHERE e.deleted = 0 AND e.kind IN ({kind_ph})
+ """,
+ (event_id, *THREAD_REPLY_KINDS),
+ ).fetchone()["n"]
+ return {
+ "zaps": int(zaps),
+ "boosts": int(boosts),
+ "quotes": int(quotes),
+ "reactions": int(reactions_total),
+ "replies": int(replies),
+ "reaction_breakdown": reaction_breakdown,
+ }
+
def get_setting(self, key: str, default: str | None = None) -> str | None:
cur = self.conn().execute("SELECT value FROM settings WHERE key=?", (key,))
row = cur.fetchone()
diff --git a/src/imwald/core/kind0_profile.py b/src/imwald/core/kind0_profile.py
new file mode 100644
index 0000000..9ab57f3
--- /dev/null
+++ b/src/imwald/core/kind0_profile.py
@@ -0,0 +1,43 @@
+"""Parse kind-0 profile JSON (NIP-01 metadata)."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+
+def parse_kind0_profile(content: str) -> dict[str, str | None]:
+ """Return display fields from kind-0 ``content`` JSON (best-effort)."""
+ empty: dict[str, str | None] = {
+ "name": None,
+ "display_name": None,
+ "about": None,
+ "picture": None,
+ "nip05": None,
+ "banner": None,
+ }
+ try:
+ d: Any = json.loads(content or "")
+ except json.JSONDecodeError:
+ return empty
+ if not isinstance(d, dict):
+ return empty
+
+ def pick(*keys: str) -> str | None:
+ for k in keys:
+ v = d.get(k)
+ if isinstance(v, str) and v.strip():
+ return v.strip()
+ return None
+
+ empty["name"] = pick("name")
+ empty["display_name"] = pick("display_name", "displayName", "username")
+ empty["about"] = pick("about", "bio")
+ empty["picture"] = pick("picture", "avatar", "image")
+ empty["nip05"] = pick("nip05")
+ empty["banner"] = pick("banner")
+ return empty
+
+
+def display_name_from_profile(p: dict[str, str | None]) -> str:
+ return (p.get("display_name") or p.get("name") or "").strip() or "anon"
diff --git a/src/imwald/core/md_render.py b/src/imwald/core/md_render.py
index c4ef9a9..8f8db91 100644
--- a/src/imwald/core/md_render.py
+++ b/src/imwald/core/md_render.py
@@ -12,6 +12,12 @@ import nh3
log = logging.getLogger(__name__)
+# Bare HTTPS image URLs in notes → Markdown image (so renderer emits ````).
+_STANDALONE_IMAGE_URL = re.compile(
+ r"(?()]+?\.(?:png|jpe?g|gif|webp|avif))(?:\?[^\s<>]*)?(?![)\]])",
+ re.IGNORECASE,
+)
+
_MARKED_PATH = Path(__file__).resolve().parents[1] / "ui" / "assets" / "vendor" / "marked.min.js"
_qjs_ctx = None
_marked_load_failed = False
@@ -67,8 +73,18 @@ def _render_markdown_fallback(md: str) -> str:
)
+def preprocess_standalone_image_urls(md: str) -> str:
+ """Turn isolated image URLs in Markdown into ```` so they render as images."""
+
+ def repl(m: re.Match[str]) -> str:
+ return f"\n})\n"
+
+ return _STANDALONE_IMAGE_URL.sub(repl, md or "")
+
+
def markdown_html_fragment(md: str) -> str:
"""Sanitized HTML fragment (body inner HTML) for embedding in templates."""
+ md = preprocess_standalone_image_urls(md)
raw = _render_marked_js(md)
if raw is None:
raw = _render_markdown_fallback(md)
@@ -90,6 +106,19 @@ def markdown_plain_summary(md: str, *, max_len: int = 100) -> str:
return plain[: max_len - 1] + "…"
+def markdown_to_plain_text(md: str, *, max_source: int = 200_000) -> str:
+ """Full plain text from Markdown (for thread bodies); keeps paragraph breaks."""
+ src = (md or "")[:max_source]
+ frag = markdown_html_fragment(src)
+ frag = re.sub(r"
", "\n", frag, flags=re.I)
+ frag = re.sub(r"
Marked deleted locally
{raw}"
f"{html.escape(ev['id'])}
" ) - self._thread.clear() + self._clear_thread_rows() self._why.setText("") + self._engagement_label.setText("") return score, why = self._ranker.score_event( ev, @@ -140,31 +276,94 @@ class FeedPage(QWidget): following=self._following, list30000_pubkeys=self._list30000_pubkeys, ) - self._why.setText(f"score={score:.2f} {json.dumps(why, ensure_ascii=False)[:120]}…") + self._why.setText(f"score={score:.2f}") + self._why.setToolTip(json.dumps(why, ensure_ascii=False, indent=2)) + + stats = self._db.event_engagement_stats(ev["id"]) + self._engagement_label.setText(_format_engagement_html(stats)) + + pk = ev["pubkey"] + prof_row = self._db.get_latest_kind0_profile(pk) + parsed = parse_kind0_profile((prof_row or {}).get("content") or "") + disp = html.escape(display_name_from_profile(parsed)) + npub = encode_npub(pk) + npub_e = html.escape(npub) + pk_short = html.escape(pk[:12] + "…") + pic_url = _safe_http_url(parsed.get("picture")) + nip05 = html.escape((parsed.get("nip05") or "").strip()) if parsed.get("nip05") else "" + about = html.escape((parsed.get("about") or "")[:280]) if parsed.get("about") else "" + avatar_html = ( + f'Trending slice (nostrarchives)
" - pk = html.escape(ev["pubkey"][:16] + "…") + tr = "{pk} · {int(ev['created_at'])}
" + "" + f"{_FEED_DOC_CSS}" + f"{eid}
" - f"" + f"{eid}
" + "" ) self._op.setHtml(body) - self._thread.clear() - for r in self._db.list_replies_to(ev["id"]): - snippet = markdown_plain_summary(r.get("content") or "", max_len=90) - line = f"k{r['kind']} {r['pubkey'][:8]}… — {snippet}" - it = QListWidgetItem(line) - it.setData(Qt.ItemDataRole.UserRole, r["id"]) - self._thread.addItem(it) + + self._clear_thread_rows() + replies = self._db.list_replies_to(ev["id"]) + pubkeys = [str(r["pubkey"]) for r in replies] + profiles = self._db.get_latest_kind0_profiles(pubkeys) + for r in replies: + rpk = str(r["pubkey"]).lower() + pr = profiles.get(rpk) or {} + rp = parse_kind0_profile(pr.get("content") or "") + rname = html.escape(display_name_from_profile(rp)) + rnpub = html.escape(encode_npub(rpk)) + plain = markdown_to_plain_text(r.get("content") or "") + + card = QFrame() + card.setObjectName("ReplyCard") + vl = QVBoxLayout(card) + vl.setContentsMargins(8, 6, 8, 8) + rk = int(r["kind"]) + head = QLabel( + f"k{rk} " + f"{rname} {rnpub}" + ) + head.setTextFormat(Qt.TextFormat.RichText) + head.setWordWrap(True) + f_small = QFont() + f_small.setPointSize(11) + head.setFont(f_small) + body_te = QPlainTextEdit() + body_te.setObjectName("ReplyBody") + body_te.setReadOnly(True) + body_te.setPlainText(plain or "(empty)") + body_te.setWordWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) + body_te.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + body_te.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + body_te.setFrameShape(QFrame.Shape.NoFrame) + body_te.document().setDocumentMargin(0) + body_te.setMinimumHeight(96) + body_te.setMaximumHeight(440) + vl.addWidget(head) + vl.addWidget(body_te) + self._thread_layout.addWidget(card) + self._thread_layout.addStretch(1) def _prev(self) -> None: if self._queue: diff --git a/src/imwald/ui/note_text_browser.py b/src/imwald/ui/note_text_browser.py new file mode 100644 index 0000000..323f503 --- /dev/null +++ b/src/imwald/ui/note_text_browser.py @@ -0,0 +1,32 @@ +"""QTextBrowser that loads remote ``" in html and "" in html
+
+
+def test_preprocess_turns_bare_image_url_into_markdown_image() -> None:
+ md = "hello\nhttps://example.com/x.png\nbye"
+ out = preprocess_standalone_image_urls(md)
+ assert "" in out