From c10a9d6e933b14dacea84f0dddb1ba1409a424aa Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 19 Apr 2026 01:33:47 +0200 Subject: [PATCH] make it a bit prettier. full threads --- src/imwald/core/database.py | 119 +++++++++++++- src/imwald/core/kind0_profile.py | 43 +++++ src/imwald/core/md_render.py | 29 ++++ src/imwald/core/nostr_engine.py | 5 +- src/imwald/ui/feed_page.py | 253 ++++++++++++++++++++++++++--- src/imwald/ui/note_text_browser.py | 32 ++++ tests/test_md_render.py | 8 +- 7 files changed, 456 insertions(+), 33 deletions(-) create mode 100644 src/imwald/core/kind0_profile.py create mode 100644 src/imwald/ui/note_text_browser.py 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 ``![](url)`` so they render as images.""" + + def repl(m: re.Match[str]) -> str: + return f"\n![]({m.group(1)})\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"", "\n\n", frag, flags=re.I) + frag = re.sub(r"", "\n", frag, flags=re.I) + plain = html.unescape(re.sub(r"<[^>]+>", "", frag)) + plain = re.sub(r"[ \t\f\v]+\n", "\n", plain) + plain = re.sub(r"\n{4,}", "\n\n\n", plain) + return plain.strip() + + _PREVIEW_CSS = """ +""" + + +def _safe_http_url(u: str | None) -> str | None: + if not u or not isinstance(u, str): + return None + u = u.strip() + if u.startswith("https://") or u.startswith("http://"): + return html.escape(u, quote=True) + return None + + +def _format_engagement_html(stats: dict[str, Any]) -> str: + parts: list[str] = [] + z = int(stats.get("zaps") or 0) + b = int(stats.get("boosts") or 0) + q = int(stats.get("quotes") or 0) + rep = int(stats.get("replies") or 0) + rtot = int(stats.get("reactions") or 0) + if z: + parts.append(f"⚡ {z}") + if rtot: + parts.append(f"👍 {rtot}") + if b: + parts.append(f"🔁 {b}") + if q: + parts.append(f"💬 {q}") + if rep: + parts.append(f"↩ {rep}") + rx = stats.get("reaction_breakdown") or [] + emoji_bits: list[str] = [] + for em, c in rx[:18]: + e = html.escape(em if em != "+" else "❤", quote=False) + if c > 1: + emoji_bits.append(f'{e}{c}') + else: + emoji_bits.append(f'{e}') + em_row = "   ".join(emoji_bits) if emoji_bits else "" + head = "  ·  ".join(parts) if parts else "no engagement in local DB yet" + if em_row: + return f"
{head}
{em_row}
" + return f"
{head}
" + class FeedPage(QWidget): def __init__(self, db: Database, engine: NostrEngine, parent=None) -> None: super().__init__(parent) + self.setObjectName("FeedPage") + self.setStyleSheet( + """ + QWidget#FeedPage { background: #ebe6dc; } + QFrame#EngagementBar { + background: #faf7f2; border: 1px solid #d9d0c3; border-radius: 10px; + padding: 8px 12px; margin-bottom: 8px; + } + QFrame#OpCard { + background: #fffcf7; border: 1px solid #d9d0c3; border-radius: 12px; + } + QScrollArea#ThreadScroll { border: 1px solid #d9d0c3; border-radius: 10px; background: #faf7f2; } + QFrame#ReplyCard { + background: #fffcf7; border: 1px solid #e5ddd0; border-radius: 8px; margin: 2px 0; + } + QLabel#ThreadTitle { font-weight: 600; color: #3d3428; padding: 4px 2px; } + QPlainTextEdit#ReplyBody { + border: none; background: transparent; font-size: 14px; color: #2a241c; + } + """ + ) self._db = db self._engine = engine self._ranker = Ranker(db) @@ -39,10 +119,44 @@ class FeedPage(QWidget): self._following: set[str] = set() self._list30000_pubkeys: set[str] = set() - self._op = QTextBrowser() + self._engagement = QFrame() + self._engagement.setObjectName("EngagementBar") + eng_layout = QVBoxLayout(self._engagement) + eng_layout.setContentsMargins(0, 0, 0, 0) + self._engagement_label = QLabel("") + self._engagement_label.setTextFormat(Qt.TextFormat.RichText) + self._engagement_label.setWordWrap(True) + eng_layout.addWidget(self._engagement_label) + + self._op_card = QFrame() + self._op_card.setObjectName("OpCard") + op_card_lay = QVBoxLayout(self._op_card) + op_card_lay.setContentsMargins(10, 10, 10, 10) + self._op = NoteTextBrowser() + self._op.setObjectName("OpNote") self._op.setOpenExternalLinks(True) + self._op.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._op.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + op_card_lay.addWidget(self._op, stretch=1) + self._why = QLabel("") - self._thread = QListWidget() + self._why.setStyleSheet("color: #6b5f4f; font-size: 12px;") + self._why.setWordWrap(True) + + self._thread_title = QLabel( + f"Thread (kinds {', '.join(str(k) for k in THREAD_REPLY_KINDS)})" + ) + self._thread_title.setObjectName("ThreadTitle") + self._thread_host = QWidget() + self._thread_layout = QVBoxLayout(self._thread_host) + self._thread_layout.setContentsMargins(6, 2, 6, 8) + self._thread_layout.setSpacing(6) + self._thread_scroll = QScrollArea() + self._thread_scroll.setObjectName("ThreadScroll") + self._thread_scroll.setWidgetResizable(True) + self._thread_scroll.setWidget(self._thread_host) + self._thread_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + prev = QPushButton("◀ Previous") next_ = QPushButton("Next ▶") prev.clicked.connect(self._prev) @@ -61,18 +175,29 @@ class FeedPage(QWidget): nav.addWidget(self._why) left = QVBoxLayout() - left.addWidget(self._op, stretch=1) + left.setSpacing(8) + left.addWidget(self._engagement) + left.addWidget(self._op_card, stretch=1) left.addLayout(nav) lw = QWidget() lw.setLayout(left) + right = QVBoxLayout() + right.setSpacing(4) + right.addWidget(self._thread_title) + right.addWidget(self._thread_scroll, stretch=1) + + rw = QWidget() + rw.setLayout(right) split = QSplitter(Qt.Orientation.Horizontal) split.addWidget(lw) - split.addWidget(self._thread) + split.addWidget(rw) split.setStretchFactor(0, 3) - split.setStretchFactor(1, 1) + split.setStretchFactor(1, 2) + split.setSizes([780, 420]) outer = QVBoxLayout(self) + outer.setContentsMargins(10, 8, 10, 8) outer.addWidget(split) def set_context( @@ -118,11 +243,21 @@ class FeedPage(QWidget): if not ev.get("deleted"): self._db.mark_feed_viewed(self._feed_viewer_key(), ev["id"]) + def _clear_thread_rows(self) -> None: + while self._thread_layout.count(): + item = self._thread_layout.takeAt(0) + if item is None: + break + w = item.widget() + if w is not None: + w.deleteLater() + def _show_current(self) -> None: if not self._queue: self._op.setPlainText("No events in local database yet — wait for relay sync.") - self._thread.clear() + self._clear_thread_rows() self._why.setText("") + self._engagement_label.setText("") return ev = self._queue[self._index % len(self._queue)] if ev.get("deleted"): @@ -131,8 +266,9 @@ class FeedPage(QWidget): f"

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'' + if pic_url + else '' + ) + nip_line = f"
{nip05}
" if nip05 else "" + about_line = f"
{about}
" if about else "" + tr = "" sr = ev.get("source_relay") or "" if sr and "nostrarchives.com" in sr: - tr = "

Trending slice (nostrarchives)

" - pk = html.escape(ev["pubkey"][:16] + "…") + tr = "
Trending slice (nostrarchives)
" + eid = html.escape(ev["id"]) md_body = markdown_html_fragment(ev.get("content") or "") body = ( - f"" - f"

Kind {int(ev['kind'])}

" - f"

{pk} · {int(ev['created_at'])}

" + "" + f"{_FEED_DOC_CSS}" + f"
" + f"{avatar_html}" + f"
{disp}
" + f"
{npub_e} · {pk_short}
" + f"{nip_line}{about_line}
" + f"
Kind {int(ev['kind'])} · {int(ev['created_at'])}
" f"{tr}" f"
{md_body}
" - 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 ```` via ``loadResource``.""" + +from __future__ import annotations + +import urllib.request +from typing import Final + +from PySide6.QtCore import QByteArray, QUrl +from PySide6.QtGui import QTextDocument +from PySide6.QtWidgets import QTextBrowser + +_MAX_IMAGE_BYTES: Final = 4 * 1024 * 1024 +_USER_AGENT = "imwald/0.1 (PySide6; +https://github.com/nostr-protocol/nostr)" + + +class NoteTextBrowser(QTextBrowser): + """Fetches HTTPS/HTTP images for rich notes (nh3 strips ``data:`` URIs on img).""" + + def loadResource(self, rtype: int, name: QUrl): # type: ignore[override] + if rtype == QTextDocument.ResourceType.ImageResource: + u = name.toString() + if u.startswith(("https://", "http://")): + try: + req = urllib.request.Request(u, headers={"User-Agent": _USER_AGENT}) + with urllib.request.urlopen(req, timeout=22) as resp: # noqa: S310 + blob = resp.read(_MAX_IMAGE_BYTES + 1) + if len(blob) > _MAX_IMAGE_BYTES: + return QByteArray() + return QByteArray(blob) + except Exception: + return QByteArray() + return super().loadResource(rtype, name) diff --git a/tests/test_md_render.py b/tests/test_md_render.py index fdc76de..72f1e03 100644 --- a/tests/test_md_render.py +++ b/tests/test_md_render.py @@ -1,4 +1,4 @@ -from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary +from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary, preprocess_standalone_image_urls def test_plain_summary_strips_markdown_noise() -> None: @@ -15,3 +15,9 @@ def test_markdown_renders_strong() -> None: def test_markdown_fenced_code() -> None: html = markdown_html_fragment("```\n1 + 1\n```") assert "
" 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 "![](https://example.com/x.png)" in out