Browse Source

make it a bit prettier. full threads

master
Silberengel 2 weeks ago
parent
commit
c10a9d6e93
  1. 119
      src/imwald/core/database.py
  2. 43
      src/imwald/core/kind0_profile.py
  3. 29
      src/imwald/core/md_render.py
  4. 5
      src/imwald/core/nostr_engine.py
  5. 253
      src/imwald/ui/feed_page.py
  6. 32
      src/imwald/ui/note_text_browser.py
  7. 8
      tests/test_md_render.py

119
src/imwald/core/database.py

@ -11,6 +11,9 @@ from typing import Any, Generator, Iterable
SCHEMA_VERSION = 2 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 = """ DDL = """
PRAGMA journal_mode=WAL; PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON; PRAGMA foreign_keys=ON;
@ -443,16 +446,18 @@ class Database:
return out return out
def list_replies_to(self, event_id: str, limit: int = 80) -> list[dict[str, Any]]: 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( cur = self.conn().execute(
""" f"""
SELECT e.id, e.pubkey, e.created_at, e.kind, e.content, e.sig, e.tags_json SELECT e.id, e.pubkey, e.created_at, e.kind, e.content, e.sig, e.tags_json
FROM events e FROM events e
JOIN tags t ON t.event_id = e.id AND t.name = 'e' AND t.value = ? 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 ORDER BY e.created_at ASC
LIMIT ? LIMIT ?
""", """,
(event_id, limit), (event_id, *THREAD_REPLY_KINDS, limit),
) )
return [ return [
{ {
@ -532,6 +537,114 @@ class Database:
row = cur.fetchone() row = cur.fetchone()
return int(row["vote"]) if row else None 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: def get_setting(self, key: str, default: str | None = None) -> str | None:
cur = self.conn().execute("SELECT value FROM settings WHERE key=?", (key,)) cur = self.conn().execute("SELECT value FROM settings WHERE key=?", (key,))
row = cur.fetchone() row = cur.fetchone()

43
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"

29
src/imwald/core/md_render.py

@ -12,6 +12,12 @@ import nh3
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Bare HTTPS image URLs in notes → Markdown image (so renderer emits ``<img>``).
_STANDALONE_IMAGE_URL = re.compile(
r"(?<![(\[])(https?://[^\s<>()]+?\.(?:png|jpe?g|gif|webp|avif))(?:\?[^\s<>]*)?(?![)\]])",
re.IGNORECASE,
)
_MARKED_PATH = Path(__file__).resolve().parents[1] / "ui" / "assets" / "vendor" / "marked.min.js" _MARKED_PATH = Path(__file__).resolve().parents[1] / "ui" / "assets" / "vendor" / "marked.min.js"
_qjs_ctx = None _qjs_ctx = None
_marked_load_failed = False _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: def markdown_html_fragment(md: str) -> str:
"""Sanitized HTML fragment (body inner HTML) for embedding in templates.""" """Sanitized HTML fragment (body inner HTML) for embedding in templates."""
md = preprocess_standalone_image_urls(md)
raw = _render_marked_js(md) raw = _render_marked_js(md)
if raw is None: if raw is None:
raw = _render_markdown_fallback(md) 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] + "" 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"<br\s*/?>", "\n", frag, flags=re.I)
frag = re.sub(r"</p\s*>", "\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 = """<style> _PREVIEW_CSS = """<style>
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:15px;margin:0;padding:12px;line-height:1.45;color:#1a1a1a;} body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:15px;margin:0;padding:12px;line-height:1.45;color:#1a1a1a;}
pre,code{font-family:ui-monospace,"Cascadia Code","Consolas",monospace;font-size:13px;} pre,code{font-family:ui-monospace,"Cascadia Code","Consolas",monospace;font-size:13px;}

5
src/imwald/core/nostr_engine.py

@ -71,7 +71,8 @@ class NostrEngine(QObject):
self._app_stop = app_stop self._app_stop = app_stop
mgr = RelayManager(on_event=on_ev, on_notice=on_notice) mgr = RelayManager(on_event=on_ev, on_notice=on_notice)
self._manager = mgr self._manager = mgr
kinds_main = [1, 20, 21, 30023, 9802, 11] # Kind 0 metadata, 6 reposts, 7 reactions, 9735 zap receipts — for feed UI + engagement counts.
kinds_main = [0, 1, 6, 7, 16, 1111, 1244, 9735, 20, 21, 30023, 9802, 11]
for u in urls: for u in urls:
mgr.register(u) mgr.register(u)
kinds = list(WISP_TRENDING_FEED_KINDS) if is_wisp_trending_relay_url(u) else kinds_main kinds = list(WISP_TRENDING_FEED_KINDS) if is_wisp_trending_relay_url(u) else kinds_main
@ -85,7 +86,7 @@ class NostrEngine(QObject):
mgr.request_subscribe( mgr.request_subscribe(
AGGR_THREAD_RELAY, AGGR_THREAD_RELAY,
"imwald-aggr", "imwald-aggr",
[{"kinds": [1], "limit": 120}], [{"kinds": [1, 16, 1111, 1244], "limit": 120}],
) )
if len(k3000_owner) == 64 and all(c in "0123456789abcdef" for c in k3000_owner): if len(k3000_owner) == 64 and all(c in "0123456789abcdef" for c in k3000_owner):
for u in urls: for u in urls:

253
src/imwald/ui/feed_page.py

@ -7,29 +7,109 @@ import json
from typing import Any from typing import Any
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QFont, QTextOption
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QListWidget, QPlainTextEdit,
QListWidgetItem,
QPushButton, QPushButton,
QScrollArea,
QSizePolicy,
QSplitter, QSplitter,
QTextBrowser,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from imwald.core.database import Database from imwald.core.database import Database, THREAD_REPLY_KINDS
from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary from imwald.core.kind0_profile import display_name_from_profile, parse_kind0_profile
from imwald.core.md_render import markdown_html_fragment, markdown_to_plain_text
from imwald.core.nip19 import encode_npub
from imwald.core.nostr_engine import NostrEngine from imwald.core.nostr_engine import NostrEngine
from imwald.core.ranker import Ranker from imwald.core.ranker import Ranker
from imwald.ui.note_text_browser import NoteTextBrowser
FEED_KINDS = (1, 20, 21, 30023, 9802, 11) FEED_KINDS = (1, 20, 21, 30023, 9802, 11)
_FEED_DOC_CSS = """
<style>
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 15px;
margin: 0; padding: 0; line-height: 1.5; color: #1e1b16; background: transparent; }
a { color: #2563eb; }
pre, code { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 13px; }
pre { background: #f0ebe3; padding: 10px; border-radius: 8px; overflow-x: auto; }
blockquote { border-left: 3px solid #c4b8a8; margin: 8px 0; padding: 4px 0 4px 12px; color: #4a4236; }
.md img { max-width: 100%; height: auto; border-radius: 8px; margin: 6px 0; }
.md p { margin: 0.45em 0; }
</style>
"""
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"⚡&nbsp;<b>{z}</b>")
if rtot:
parts.append(f"👍&nbsp;<b>{rtot}</b>")
if b:
parts.append(f"🔁&nbsp;<b>{b}</b>")
if q:
parts.append(f"💬&nbsp;<b>{q}</b>")
if rep:
parts.append(f"↩&nbsp;<b>{rep}</b>")
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'<span style="font-size:18px" title="{e}×{c}">{e}<sub style="font-size:11px">{c}</sub></span>')
else:
emoji_bits.append(f'<span style="font-size:18px">{e}</span>')
em_row = " &nbsp; ".join(emoji_bits) if emoji_bits else ""
head = " &nbsp;·&nbsp; ".join(parts) if parts else "no engagement in local DB yet"
if em_row:
return f"<div style='margin-bottom:6px'>{head}</div><div>{em_row}</div>"
return f"<div>{head}</div>"
class FeedPage(QWidget): class FeedPage(QWidget):
def __init__(self, db: Database, engine: NostrEngine, parent=None) -> None: def __init__(self, db: Database, engine: NostrEngine, parent=None) -> None:
super().__init__(parent) 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._db = db
self._engine = engine self._engine = engine
self._ranker = Ranker(db) self._ranker = Ranker(db)
@ -39,10 +119,44 @@ class FeedPage(QWidget):
self._following: set[str] = set() self._following: set[str] = set()
self._list30000_pubkeys: 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.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._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") prev = QPushButton("◀ Previous")
next_ = QPushButton("Next ▶") next_ = QPushButton("Next ▶")
prev.clicked.connect(self._prev) prev.clicked.connect(self._prev)
@ -61,18 +175,29 @@ class FeedPage(QWidget):
nav.addWidget(self._why) nav.addWidget(self._why)
left = QVBoxLayout() 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) left.addLayout(nav)
lw = QWidget() lw = QWidget()
lw.setLayout(left) 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 = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(lw) split.addWidget(lw)
split.addWidget(self._thread) split.addWidget(rw)
split.setStretchFactor(0, 3) split.setStretchFactor(0, 3)
split.setStretchFactor(1, 1) split.setStretchFactor(1, 2)
split.setSizes([780, 420])
outer = QVBoxLayout(self) outer = QVBoxLayout(self)
outer.setContentsMargins(10, 8, 10, 8)
outer.addWidget(split) outer.addWidget(split)
def set_context( def set_context(
@ -118,11 +243,21 @@ class FeedPage(QWidget):
if not ev.get("deleted"): if not ev.get("deleted"):
self._db.mark_feed_viewed(self._feed_viewer_key(), ev["id"]) 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: def _show_current(self) -> None:
if not self._queue: if not self._queue:
self._op.setPlainText("No events in local database yet — wait for relay sync.") self._op.setPlainText("No events in local database yet — wait for relay sync.")
self._thread.clear() self._clear_thread_rows()
self._why.setText("") self._why.setText("")
self._engagement_label.setText("")
return return
ev = self._queue[self._index % len(self._queue)] ev = self._queue[self._index % len(self._queue)]
if ev.get("deleted"): if ev.get("deleted"):
@ -131,8 +266,9 @@ class FeedPage(QWidget):
f"<p><i>Marked deleted locally</i></p><pre>{raw}</pre>" f"<p><i>Marked deleted locally</i></p><pre>{raw}</pre>"
f"<p style='color:gray'>{html.escape(ev['id'])}</p>" f"<p style='color:gray'>{html.escape(ev['id'])}</p>"
) )
self._thread.clear() self._clear_thread_rows()
self._why.setText("") self._why.setText("")
self._engagement_label.setText("")
return return
score, why = self._ranker.score_event( score, why = self._ranker.score_event(
ev, ev,
@ -140,31 +276,94 @@ class FeedPage(QWidget):
following=self._following, following=self._following,
list30000_pubkeys=self._list30000_pubkeys, 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'<img src="{pic_url}" width="52" height="52" style="border-radius:10px;object-fit:cover;vertical-align:middle;margin-right:10px"/>'
if pic_url
else '<span style="display:inline-block;width:52px;height:52px;border-radius:10px;background:#d9d0c3;margin-right:10px;vertical-align:middle"></span>'
)
nip_line = f"<div style='color:#6b5f4f;font-size:13px;margin-top:4px'>{nip05}</div>" if nip05 else ""
about_line = f"<div style='color:#5c5246;font-size:13px;margin-top:6px'>{about}</div>" if about else ""
tr = "" tr = ""
sr = ev.get("source_relay") or "" sr = ev.get("source_relay") or ""
if sr and "nostrarchives.com" in sr: if sr and "nostrarchives.com" in sr:
tr = "<p><i>Trending slice (nostrarchives)</i></p>" tr = "<div style='color:#7a6b55;font-size:13px;margin:6px 0'><i>Trending slice (nostrarchives)</i></div>"
pk = html.escape(ev["pubkey"][:16] + "")
eid = html.escape(ev["id"]) eid = html.escape(ev["id"])
md_body = markdown_html_fragment(ev.get("content") or "") md_body = markdown_html_fragment(ev.get("content") or "")
body = ( body = (
f"<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>" "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"<h2>Kind {int(ev['kind'])}</h2>" f"{_FEED_DOC_CSS}</head><body>"
f"<p><b>{pk}</b> · {int(ev['created_at'])}</p>" f"<div style='display:flex;align-items:flex-start;margin-bottom:12px'>"
f"{avatar_html}"
f"<div style='flex:1'><div style='font-size:18px;font-weight:600'>{disp}</div>"
f"<div style='color:#6b5f4f;font-size:13px'>{npub_e} · {pk_short}</div>"
f"{nip_line}{about_line}</div></div>"
f"<div style='color:#7a6b55;font-size:13px;margin-bottom:8px'>Kind {int(ev['kind'])} · {int(ev['created_at'])}</div>"
f"{tr}" f"{tr}"
f"<div class=\"md\">{md_body}</div>" f"<div class=\"md\">{md_body}</div>"
f"<p style='color:gray'>{eid}</p>" f"<p style='color:#9a8b78;font-size:12px;margin-top:14px'>{eid}</p>"
f"</body></html>" "</body></html>"
) )
self._op.setHtml(body) self._op.setHtml(body)
self._thread.clear()
for r in self._db.list_replies_to(ev["id"]): self._clear_thread_rows()
snippet = markdown_plain_summary(r.get("content") or "", max_len=90) replies = self._db.list_replies_to(ev["id"])
line = f"k{r['kind']} {r['pubkey'][:8]}… — {snippet}" pubkeys = [str(r["pubkey"]) for r in replies]
it = QListWidgetItem(line) profiles = self._db.get_latest_kind0_profiles(pubkeys)
it.setData(Qt.ItemDataRole.UserRole, r["id"]) for r in replies:
self._thread.addItem(it) 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"<span style='color:#8a7b6a;font-size:11px'>k{rk}</span> &nbsp; "
f"<b>{rname}</b> &nbsp; <span style='color:#7a6b55;font-size:12px'>{rnpub}</span>"
)
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: def _prev(self) -> None:
if self._queue: if self._queue:

32
src/imwald/ui/note_text_browser.py

@ -0,0 +1,32 @@
"""QTextBrowser that loads remote ``<img src="https://…">`` 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)

8
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: def test_plain_summary_strips_markdown_noise() -> None:
@ -15,3 +15,9 @@ def test_markdown_renders_strong() -> None:
def test_markdown_fenced_code() -> None: def test_markdown_fenced_code() -> None:
html = markdown_html_fragment("```\n1 + 1\n```") html = markdown_html_fragment("```\n1 + 1\n```")
assert "<pre>" in html and "<code>" in html assert "<pre>" in html and "<code>" 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

Loading…
Cancel
Save