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 ```` 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"
", "\n\n", frag, flags=re.I)
+ frag = re.sub(r"(div|blockquote|h[1-6]|li|tr)\s*>", "\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 "" in out