From 1ea1b0a192586d63fad34ae00a7c56dc04372d2b Mon Sep 17 00:00:00 2001
From: Silberengel
Date: Sun, 19 Apr 2026 11:55:52 +0200
Subject: [PATCH] change to dark theme handle nostr addresses
---
pyproject.toml | 5 +
src/imwald/app.py | 2 +
src/imwald/core/author_html.py | 116 ++++++++++++
src/imwald/core/database.py | 61 +++++-
src/imwald/core/md_render.py | 94 ++++++++--
src/imwald/core/nip19.py | 113 ++++++++++-
src/imwald/core/nostr_crypto.py | 8 +-
src/imwald/core/nostr_entity_render.py | 141 ++++++++++++++
src/imwald/core/ranker.py | 2 +-
src/imwald/core/relay_manager.py | 4 +-
src/imwald/ui/composer_dialog.py | 13 +-
src/imwald/ui/db_admin_page.py | 10 +-
src/imwald/ui/feed_page.py | 208 ++++++++++++---------
src/imwald/ui/main_window.py | 4 +-
src/imwald/ui/markdown_editor_widget.py | 6 +-
src/imwald/ui/note_text_browser.py | 2 +-
src/imwald/ui/theme.py | 239 ++++++++++++++++++++++++
tests/test_nostr_entity_render.py | 85 +++++++++
18 files changed, 985 insertions(+), 128 deletions(-)
create mode 100644 src/imwald/core/author_html.py
create mode 100644 src/imwald/core/nostr_entity_render.py
create mode 100644 src/imwald/ui/theme.py
create mode 100644 tests/test_nostr_entity_render.py
diff --git a/pyproject.toml b/pyproject.toml
index 3d151f2..3088a63 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,3 +47,8 @@ pythonVersion = "3.11"
# So third-party stubs (e.g. Pillow → ``PIL``) resolve when using ``.venv`` at the repo root.
venvPath = "."
venv = ".venv"
+# Desktop app + sqlite/Qt stubs surface a lot of ``Any``; keep checks useful without IDE noise.
+typeCheckingMode = "standard"
+reportMissingTypeStubs = "none"
+reportAny = "none"
+reportExplicitAny = "none"
diff --git a/src/imwald/app.py b/src/imwald/app.py
index 4b7f294..629b40d 100644
--- a/src/imwald/app.py
+++ b/src/imwald/app.py
@@ -12,6 +12,7 @@ from imwald.config import db_path
from imwald.core.database import Database
from imwald.core.nostr_engine import NostrEngine
from imwald.ui.main_window import MainWindow
+from imwald.ui.theme import apply_application_theme
def _set_comfortable_default_font(app: QApplication) -> None:
@@ -34,6 +35,7 @@ def main() -> None:
app = QApplication(sys.argv)
app.setApplicationName("imwald")
app.setOrganizationName("imwald")
+ apply_application_theme(app)
_set_comfortable_default_font(app)
db = Database(db_path())
diff --git a/src/imwald/core/author_html.py b/src/imwald/core/author_html.py
new file mode 100644
index 0000000..2568447
--- /dev/null
+++ b/src/imwald/core/author_html.py
@@ -0,0 +1,116 @@
+"""Reusable author avatar + name snippets for rich HTML (feed, embeds, nostr badges)."""
+
+from __future__ import annotations
+
+import html
+
+from imwald.core.kind0_profile import display_name_from_profile
+
+
+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 avatar_img_or_placeholder(
+ parsed: dict[str, str | None],
+ size_px: int,
+ *,
+ border_hex: str = "#2a3d34",
+) -> str:
+ pic = safe_http_url(parsed.get("picture"))
+ r = max(6, size_px // 5)
+ if pic:
+ return (
+ f'
'
+ )
+ return (
+ f''
+ )
+
+
+def feed_op_author_block_html(
+ parsed: dict[str, str | None],
+ npub_bech: str,
+ pk_short: str,
+ nip_line_html: str,
+ about_line_html: str,
+ *,
+ text: str,
+ muted: str,
+ dim: str,
+ border: str,
+) -> str:
+ """Top-of-note author row: picture, display name, npub, optional nip05/about lines."""
+ disp = html.escape(display_name_from_profile(parsed))
+ av = avatar_img_or_placeholder(parsed, 52, border_hex=border)
+ npub_e = html.escape(npub_bech)
+ pk_s = html.escape(pk_short)
+ return (
+ f''
+ f"{av}"
+ f'
'
+ f'
{disp}
'
+ f'
{npub_e} · {pk_s}
'
+ f"{nip_line_html}{about_line_html}"
+ f"
"
+ )
+
+
+def thread_reply_author_row_html(
+ parsed: dict[str, str | None],
+ kind: int,
+ npub_bech: str,
+ *,
+ text: str,
+ muted: str,
+ dim: str,
+ border: str,
+) -> str:
+ """Single-row author badge for thread replies (avatar + kind + name + npub)."""
+ av = avatar_img_or_placeholder(parsed, 40, border_hex=border)
+ name = html.escape(display_name_from_profile(parsed))
+ npub_e = html.escape(npub_bech)
+ return (
+ ''
+ f"{av}"
+ '
'
+ f'k{int(kind)} '
+ f'{name} '
+ f'{npub_e}'
+ "
"
+ )
+
+
+def inline_profile_badge_html(parsed: dict[str, str | None], pubkey_hex: str, npub_tooltip: str, badge_style: str) -> str:
+ """Small inline pill: optional avatar + @name (for ``nostr:npub`` in Markdown)."""
+ name = html.escape(display_name_from_profile(parsed))
+ tip = html.escape(npub_tooltip)
+ pic = safe_http_url(parsed.get("picture"))
+ img = ""
+ if pic:
+ img = (
+ f'
'
+ )
+ inner = f"{img}@{name}"
+ return f'{inner}'
+
+
+def embed_author_row_html(parsed: dict[str, str | None], head_line_html: str) -> str:
+ """Avatar + headline row for embedded events."""
+ av = avatar_img_or_placeholder(parsed, 36, border_hex="#2a3d34")
+ return (
+ ''
+ f"{av}"
+ f'
'
+ f"{head_line_html}"
+ "
"
+ )
diff --git a/src/imwald/core/database.py b/src/imwald/core/database.py
index 8de4ba9..3053fa9 100644
--- a/src/imwald/core/database.py
+++ b/src/imwald/core/database.py
@@ -7,10 +7,27 @@ import sqlite3
import time
from contextlib import contextmanager
from pathlib import Path
-from typing import Any, Generator, Iterable
+from typing import Any, Generator, Iterable, TypedDict, cast
SCHEMA_VERSION = 2
+
+class Kind0ProfileSummary(TypedDict):
+ content: str
+ created_at: int
+
+
+class StoredEventRow(TypedDict):
+ id: str
+ pubkey: str
+ created_at: int
+ kind: int
+ content: str
+ sig: str
+ tags: list[list[str]]
+ deleted: bool
+ source_relay: str | None
+
# 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)
@@ -310,7 +327,7 @@ class Database:
"tags": json.loads(row["tags_json"] or "[]"),
}
- def get_event(self, event_id: str) -> dict[str, Any] | None:
+ def get_event(self, event_id: str) -> StoredEventRow | None:
cur = self.conn().execute(
"SELECT id,pubkey,created_at,kind,content,sig,tags_json,deleted,source_relay FROM events WHERE id=?",
(event_id,),
@@ -321,11 +338,39 @@ class Database:
return {
"id": row["id"],
"pubkey": row["pubkey"],
- "created_at": row["created_at"],
- "kind": row["kind"],
+ "created_at": int(row["created_at"]),
+ "kind": int(row["kind"]),
"content": row["content"],
"sig": row["sig"],
- "tags": json.loads(row["tags_json"] or "[]"),
+ "tags": cast(list[list[str]], json.loads(row["tags_json"] or "[]")),
+ "deleted": bool(row["deleted"]),
+ "source_relay": row["source_relay"],
+ }
+
+ def get_event_by_addressable(self, kind: int, pubkey: str, d_tag: str) -> StoredEventRow | None:
+ """Latest local event for this replaceable / addressable coordinate (``d`` tag + kind + author)."""
+ cur = self.conn().execute(
+ """
+ SELECT e.id, e.pubkey, e.created_at, e.kind, e.content, e.sig, e.tags_json, e.deleted, e.source_relay
+ FROM events e
+ INNER JOIN tags td ON td.event_id = e.id AND td.name = 'd' AND td.value = ?
+ WHERE e.kind = ? AND lower(e.pubkey) = lower(?)
+ ORDER BY e.created_at DESC
+ LIMIT 1
+ """,
+ (d_tag, int(kind), pubkey),
+ )
+ row = cur.fetchone()
+ if not row:
+ return None
+ return {
+ "id": row["id"],
+ "pubkey": row["pubkey"],
+ "created_at": int(row["created_at"]),
+ "kind": int(row["kind"]),
+ "content": row["content"],
+ "sig": row["sig"],
+ "tags": cast(list[list[str]], json.loads(row["tags_json"] or "[]")),
"deleted": bool(row["deleted"]),
"source_relay": row["source_relay"],
}
@@ -537,7 +582,7 @@ 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:
+ def get_latest_kind0_profile(self, pubkey: str) -> Kind0ProfileSummary | None:
"""Latest non-deleted kind-0 for ``pubkey`` (hex), or None."""
cur = self.conn().execute(
"""
@@ -552,7 +597,7 @@ class Database:
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]]:
+ def get_latest_kind0_profiles(self, pubkeys: Iterable[str]) -> dict[str, Kind0ProfileSummary]:
"""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:
@@ -571,7 +616,7 @@ class Database:
""",
pks,
)
- out: dict[str, dict[str, Any]] = {}
+ out: dict[str, Kind0ProfileSummary] = {}
for row in cur:
pk = str(row["pk"])
out[pk] = {"content": row["content"] or "", "created_at": int(row["created_at"])}
diff --git a/src/imwald/core/md_render.py b/src/imwald/core/md_render.py
index 17a7aa1..5ce42e6 100644
--- a/src/imwald/core/md_render.py
+++ b/src/imwald/core/md_render.py
@@ -6,10 +6,18 @@ import html
import json
import logging
import re
+from copy import deepcopy
+from collections.abc import MutableMapping
from pathlib import Path
+from typing import TYPE_CHECKING, cast
import nh3
+from imwald.core.nostr_entity_render import preprocess_nostr_entities
+
+if TYPE_CHECKING:
+ from imwald.core.database import Database
+
log = logging.getLogger(__name__)
# Bare HTTPS image URLs in notes → Markdown image (so renderer emits ``
``).
@@ -22,6 +30,62 @@ _MARKED_PATH = Path(__file__).resolve().parents[1] / "ui" / "assets" / "vendor"
_qjs_ctx = None
_marked_load_failed = False
+_nh3_attrs_merged: dict[str, set[str]] | None = None
+_NH3_STYLE_FILTER = frozenset({
+ "color",
+ "background",
+ "border",
+ "border-radius",
+ "padding",
+ "margin",
+ "font-size",
+ "font-weight",
+ "display",
+ "max-height",
+ "min-height",
+ "min-width",
+ "max-width",
+ "width",
+ "height",
+ "overflow",
+ "line-height",
+ "border-left",
+ "white-space",
+ "opacity",
+ "font-family",
+ "flex",
+ "flex-shrink",
+ "align-items",
+ "gap",
+ "object-fit",
+ "vertical-align",
+})
+
+
+def _nh3_attributes() -> dict[str, set[str]]:
+ global _nh3_attrs_merged
+ if _nh3_attrs_merged is None:
+ raw = cast(MutableMapping[str, set[str]], deepcopy(nh3.ALLOWED_ATTRIBUTES))
+ for tag in ("span", "div"):
+ s = raw.get(tag)
+ if s is None:
+ s = set()
+ raw[tag] = s
+ s.update({"class", "style", "title"})
+ img_a = raw.get("img")
+ if img_a is not None:
+ img_a.add("style")
+ _nh3_attrs_merged = dict(raw)
+ return _nh3_attrs_merged
+
+
+def _nh3_clean(html: str) -> str:
+ return nh3.clean(
+ html,
+ attributes=_nh3_attributes(),
+ filter_style_properties=_NH3_STYLE_FILTER,
+ )
+
def _marked_quickjs_ctx():
"""Singleton QuickJS context with ``marked`` loaded, or None if unavailable."""
@@ -82,23 +146,26 @@ def preprocess_standalone_image_urls(md: str) -> str:
return _STANDALONE_IMAGE_URL.sub(repl, md or "")
-def markdown_html_fragment(md: str) -> str:
+def markdown_html_fragment(md: str, *, db: Database | None = None) -> str:
"""Sanitized HTML fragment (body inner HTML) for embedding in templates."""
- md = preprocess_standalone_image_urls(md)
+ # Standalone image URLs first: nostr preprocessing injects ``
``; the
+ # image-url pass must not run on that HTML or it corrupts attributes into Markdown images.
+ md = preprocess_standalone_image_urls(md or "")
+ md = preprocess_nostr_entities(md, db)
raw = _render_marked_js(md)
if raw is None:
raw = _render_markdown_fallback(md)
- return nh3.clean(raw)
+ return _nh3_clean(raw)
-def markdown_plain_summary(md: str, *, max_len: int = 100) -> str:
+def markdown_plain_summary(md: str, *, max_len: int = 100, db: Database | None = None) -> str:
"""
Plain-text one-line preview for list widgets: same pipeline as ``markdown_html_fragment``,
then strip tags and collapse whitespace (no Markdown noise in the UI chrome).
"""
# Cap source length so list views (search, notifications, threads) do not parse huge notes.
src = (md or "")[:1200]
- frag = markdown_html_fragment(src)
+ frag = markdown_html_fragment(src, db=db)
plain = html.unescape(re.sub(r"<[^>]+>", " ", frag))
plain = re.sub(r"\s+", " ", plain).strip()
if len(plain) <= max_len:
@@ -106,10 +173,10 @@ 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:
+def markdown_to_plain_text(md: str, *, max_source: int = 200_000, db: Database | None = None) -> str:
"""Full plain text from Markdown (for thread bodies); keeps paragraph breaks."""
src = (md or "")[:max_source]
- frag = markdown_html_fragment(src)
+ frag = markdown_html_fragment(src, db=db)
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)
@@ -120,19 +187,20 @@ def markdown_to_plain_text(md: str, *, max_source: int = 200_000) -> str:
_PREVIEW_CSS = """"""
-def markdown_html_document(md: str) -> str:
+def markdown_html_document(md: str, *, db: Database | None = None) -> str:
"""Full HTML document for ``QTextBrowser`` preview panes."""
- inner = markdown_html_fragment(md)
+ inner = markdown_html_fragment(md, db=db)
return (
""
f"{_PREVIEW_CSS}{inner}"
diff --git a/src/imwald/core/nip19.py b/src/imwald/core/nip19.py
index d0aa3bc..cdee3eb 100644
--- a/src/imwald/core/nip19.py
+++ b/src/imwald/core/nip19.py
@@ -1,10 +1,43 @@
-"""Minimal NIP-19: decode nsec / npub hex payload."""
+"""Minimal NIP-19: decode nsec / npub hex payload and other bech32 entities."""
from __future__ import annotations
+from typing import Literal, NotRequired, TypedDict
+
import bech32
+class NpubDecoded(TypedDict):
+ hrp: Literal["npub"]
+ pubkey: str
+
+
+class NoteDecoded(TypedDict):
+ hrp: Literal["note"]
+ event_id: str
+
+
+class NprofileDecoded(TypedDict):
+ hrp: Literal["nprofile"]
+ pubkey: str
+
+
+class NeventDecoded(TypedDict):
+ hrp: Literal["nevent"]
+ event_id: str
+ pubkey: NotRequired[str]
+
+
+class NaddrDecoded(TypedDict):
+ hrp: Literal["naddr"]
+ pubkey: str
+ identifier: str
+ address_kind: int
+
+
+Nip19Decoded = NpubDecoded | NoteDecoded | NprofileDecoded | NeventDecoded | NaddrDecoded
+
+
def decode_nsec(nsec: str) -> bytes:
hrp, data = bech32.bech32_decode(nsec.strip())
if hrp != "nsec" or data is None:
@@ -32,3 +65,81 @@ def encode_npub(pubkey_hex: str) -> str:
if conv is None:
raise ValueError("invalid pubkey encoding")
return bech32.bech32_encode("npub", conv)
+
+
+def _parse_tlv(payload: bytes) -> dict[int, bytes]:
+ out: dict[int, bytes] = {}
+ i = 0
+ n = len(payload)
+ while i + 2 <= n:
+ typ = payload[i]
+ ln = payload[i + 1]
+ i += 2
+ if i + ln > n:
+ break
+ out[typ] = payload[i : i + ln]
+ i += ln
+ return out
+
+
+def decode_nip19_entity(bech32_code: str) -> Nip19Decoded | None:
+ """
+ Decode a NIP-19 bech32 string (``npub1…``, ``note1…``, ``nprofile1…``, ``nevent1…``, ``naddr1…``).
+
+ Returns a dict with at least ``"hrp"`` and optional ``pubkey`` / ``event_id`` / ``address_kind`` /
+ ``identifier`` (hex pubkeys are lowercase, no ``0x``).
+ """
+ s = bech32_code.strip().lower()
+ if not s or "1" not in s:
+ return None
+ hrp, data = bech32.bech32_decode(s)
+ if hrp is None or data is None:
+ return None
+ conv = bech32.convertbits(list(data), 5, 8, False)
+ if conv is None:
+ return None
+ payload = bytes(conv)
+ if hrp == "npub":
+ if len(payload) != 32:
+ return None
+ return {"hrp": "npub", "pubkey": payload.hex()}
+ if hrp == "note":
+ if len(payload) != 32:
+ return None
+ return {"hrp": "note", "event_id": payload.hex()}
+ if hrp == "nprofile":
+ tlv = _parse_tlv(payload)
+ pk = tlv.get(1)
+ if not isinstance(pk, bytes) or len(pk) != 32:
+ return None
+ return {"hrp": "nprofile", "pubkey": pk.hex()}
+ if hrp == "nevent":
+ tlv = _parse_tlv(payload)
+ eid = tlv.get(2)
+ if not isinstance(eid, bytes) or len(eid) != 32:
+ return None
+ out: NeventDecoded = {"hrp": "nevent", "event_id": eid.hex()}
+ pk0 = tlv.get(0)
+ if isinstance(pk0, bytes) and len(pk0) == 32:
+ out["pubkey"] = pk0.hex()
+ return out
+ if hrp == "naddr":
+ tlv = _parse_tlv(payload)
+ pk = tlv.get(0)
+ ident_b = tlv.get(1)
+ kd = tlv.get(2)
+ if not isinstance(pk, bytes) or len(pk) != 32:
+ return None
+ if not isinstance(ident_b, bytes) or not ident_b:
+ return None
+ if not isinstance(kd, bytes) or len(kd) != 4:
+ return None
+ kind = int.from_bytes(kd, "big")
+ ident_s = ident_b.decode("utf-8", errors="replace")
+ return {
+ "hrp": "naddr",
+ "pubkey": pk.hex(),
+ "identifier": ident_s,
+ "address_kind": kind,
+ }
+ return None
diff --git a/src/imwald/core/nostr_crypto.py b/src/imwald/core/nostr_crypto.py
index 340bfb1..b0a64c2 100644
--- a/src/imwald/core/nostr_crypto.py
+++ b/src/imwald/core/nostr_crypto.py
@@ -10,12 +10,14 @@ from coincurve import PrivateKey
from coincurve.keys import PublicKeyXOnly
-def serialize_event_for_id(pubkey: str, created_at: int, kind: int, tags: list, content: str) -> str:
+def serialize_event_for_id(
+ pubkey: str, created_at: int, kind: int, tags: list[list[str]], content: str
+) -> str:
arr = [0, pubkey, created_at, kind, tags, content]
return json.dumps(arr, ensure_ascii=False, separators=(",", ":"))
-def event_id_hex(pubkey: str, created_at: int, kind: int, tags: list, content: str) -> str:
+def event_id_hex(pubkey: str, created_at: int, kind: int, tags: list[list[str]], content: str) -> str:
ser = serialize_event_for_id(pubkey, created_at, kind, tags, content)
return sha256(ser.encode("utf-8")).hexdigest()
@@ -74,7 +76,7 @@ def build_signed_event(
*,
created_at: int,
kind: int,
- tags: list,
+ tags: list[list[str]],
content: str,
) -> dict[str, Any]:
pubkey = pubkey_hex_from_secret(secret)
diff --git a/src/imwald/core/nostr_entity_render.py b/src/imwald/core/nostr_entity_render.py
new file mode 100644
index 0000000..3fe6985
--- /dev/null
+++ b/src/imwald/core/nostr_entity_render.py
@@ -0,0 +1,141 @@
+"""Replace ``nostr:…`` NIP-19 references in Markdown with HTML badges / embeds (needs ``Database``)."""
+
+from __future__ import annotations
+
+import html
+import re
+
+from .author_html import embed_author_row_html, inline_profile_badge_html
+from imwald.core.database import Database
+from imwald.core.kind0_profile import display_name_from_profile, parse_kind0_profile
+from imwald.core.nip19 import decode_nip19_entity, encode_npub
+
+NOSTR_URI_RE = re.compile(
+ r"nostr:((?:npub|nprofile|note|nevent|naddr)1[ac-hj-np-z02-9]+)",
+ re.IGNORECASE,
+)
+
+_BADGE_STYLE = (
+ "display:inline-flex;align-items:center;padding:2px 10px;margin:0 2px;border-radius:999px;"
+ "background:#15251f;border:1px solid #2a9d6f;color:#b8f5d0;font-size:0.95em;font-weight:600"
+)
+_EMBED_STYLE = (
+ "margin:10px 0;padding:10px 12px;border-radius:10px;border:1px solid #2a3d34;"
+ "background:#0a100d;max-height:240px;overflow:auto"
+)
+
+
+def _profile_badge_html(db: Database, pubkey_hex: str) -> str:
+ row = db.get_latest_kind0_profile(pubkey_hex)
+ parsed = parse_kind0_profile(row["content"] if row else "")
+ try:
+ npub = encode_npub(pubkey_hex)
+ except ValueError:
+ npub = pubkey_hex[:16] + "…"
+ tip = npub
+ inner = inline_profile_badge_html(parsed, pubkey_hex, tip, _BADGE_STYLE)
+ # Single-line HTML: newlines around inline badges confuse Python-Markdown’s raw-HTML pass.
+ return inner
+
+
+def _event_embed_html(db: Database, event_id_hex: str) -> str:
+ ev = db.get_event(event_id_hex)
+ if not ev:
+ short = html.escape(event_id_hex[:28] + "…")
+ return (
+ "\n\n"
+ f''
+ f"Event not in local database yet · {short}
\n\n"
+ )
+ pk = ev["pubkey"]
+ prof = db.get_latest_kind0_profile(pk)
+ p = parse_kind0_profile(prof["content"] if prof else "")
+ author = display_name_from_profile(p)
+ head_plain = f"kind {ev['kind']} · @{author} · {ev['created_at']}"
+ if ev.get("deleted"):
+ body = html.escape((ev.get("content") or "")[:400])
+ head_plain += " · deleted locally"
+ else:
+ raw = (ev.get("content") or "").replace("\n", " ")
+ raw = re.sub(r"\s+", " ", raw).strip()
+ body = html.escape(raw[:400])
+ if len(raw) > 400:
+ body += "…"
+ head = html.escape(head_plain)
+ head_row = embed_author_row_html(p, head)
+ return (
+ "\n\n"
+ f''
+ f"{head_row}"
+ f'
{body}
'
+ "
\n\n"
+ )
+
+
+def _replacement_core(m: re.Match[str], db: Database) -> str:
+ bech = m.group(1).lower()
+ dec = decode_nip19_entity(bech)
+ if dec is None:
+ return m.group(0)
+ match dec:
+ case {"hrp": "npub", "pubkey": pk}:
+ if len(pk) != 64:
+ return m.group(0)
+ return _profile_badge_html(db, pk)
+ case {"hrp": "nprofile", "pubkey": pk}:
+ if len(pk) != 64:
+ return m.group(0)
+ return _profile_badge_html(db, pk)
+ case {"hrp": "note", "event_id": eid}:
+ if len(eid) != 64:
+ return m.group(0)
+ return _event_embed_html(db, eid)
+ case {"hrp": "nevent", "event_id": eid}:
+ if len(eid) != 64:
+ return m.group(0)
+ return _event_embed_html(db, eid)
+ case {"hrp": "naddr", "pubkey": pk, "address_kind": kind, "identifier": ident}:
+ if len(pk) != 64 or not ident:
+ return m.group(0)
+ ev = db.get_event_by_addressable(kind, pk, ident)
+ if not ev:
+ hint = html.escape(f"naddr k{kind} · {ident[:40]}{'…' if len(ident) > 40 else ''}")
+ return (
+ "\n\n"
+ f''
+ f"Addressable event not in local database yet · {hint}
\n\n"
+ )
+ return _event_embed_html(db, ev["id"])
+ case _:
+ return m.group(0)
+
+
+def preprocess_nostr_entities(md: str, db: Database | None) -> str:
+ """Turn ``nostr:npub…`` / ``nprofile`` / ``note`` / ``nevent`` / ``naddr`` into inline HTML (before Markdown)."""
+ if not db or not md:
+ return md
+
+ def sub_segment(segment: str, abs_start: int) -> str:
+ def repl(m: re.Match[str]) -> str:
+ pos = abs_start + m.start()
+ if pos >= 2 and md[pos - 2 : pos] == "](":
+ return m.group(0)
+ return _replacement_core(m, db)
+
+ return NOSTR_URI_RE.sub(repl, segment)
+
+ pieces: list[str] = []
+ i = 0
+ while i < len(md):
+ j = md.find("```", i)
+ end = len(md) if j == -1 else j
+ pieces.append(sub_segment(md[i:end], i))
+ if j == -1:
+ break
+ k = md.find("```", j + 3)
+ if k == -1:
+ pieces.append(md[j:])
+ break
+ pieces.append(md[j : k + 3])
+ i = k + 3
+ return "".join(pieces)
diff --git a/src/imwald/core/ranker.py b/src/imwald/core/ranker.py
index 7d230b0..739d83e 100644
--- a/src/imwald/core/ranker.py
+++ b/src/imwald/core/ranker.py
@@ -22,7 +22,7 @@ WEIGHT_ZAP = 3.0
WEIGHT_LOCAL_VOTE = 1.0
-def _tags_contain_repost(tags: list) -> bool:
+def _tags_contain_repost(tags: list[list[str]]) -> bool:
for t in tags:
if not t:
continue
diff --git a/src/imwald/core/relay_manager.py b/src/imwald/core/relay_manager.py
index 36ad241..248761d 100644
--- a/src/imwald/core/relay_manager.py
+++ b/src/imwald/core/relay_manager.py
@@ -13,7 +13,7 @@ from enum import Enum
from typing import Any, Callable, Coroutine
import websockets
-from websockets.client import WebSocketClientProtocol
+from websockets.asyncio.client import ClientConnection
log = logging.getLogger(__name__)
@@ -33,7 +33,7 @@ class RelayConn:
last_error: str | None = None
last_connected_at: float | None = None
backoff_until: float = 0.0
- _ws: WebSocketClientProtocol | None = field(default=None, repr=False)
+ _ws: ClientConnection | None = field(default=None, repr=False)
_task: asyncio.Task[None] | None = field(default=None, repr=False)
def status_line(self) -> str:
diff --git a/src/imwald/ui/composer_dialog.py b/src/imwald/ui/composer_dialog.py
index 66026ad..64d3fa4 100644
--- a/src/imwald/ui/composer_dialog.py
+++ b/src/imwald/ui/composer_dialog.py
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
)
from imwald.core.accounts_store import StoredAccount, unlock_secret
+from imwald.core.database import Database, StoredEventRow
from imwald.core.nostr_crypto import build_signed_event
from imwald.core.nostr_publish import publish_to_relays_sync
from imwald.ui.markdown_editor_widget import MarkdownBodyEditor
@@ -33,10 +34,11 @@ class ComposerDialog(QDialog):
self,
parent=None,
*,
- edit_from: dict[str, Any] | None = None,
+ edit_from: StoredEventRow | dict[str, Any] | None = None,
account: StoredAccount,
password: str | None = None,
write_relays: list[str],
+ db: Database | None = None,
) -> None:
super().__init__(parent)
self.setWindowTitle("New event" if edit_from is None else "Edit event (clone)")
@@ -45,7 +47,7 @@ class ComposerDialog(QDialog):
self._password = password
self._edit_from = edit_from
self._write_relays = list(write_relays)
- self.last_published: dict | None = None
+ self.last_published: dict[str, Any] | None = None
self._kind = QSpinBox()
self._kind.setRange(0, 99999)
@@ -57,7 +59,7 @@ class ComposerDialog(QDialog):
self._tags = QLineEdit()
self._tags.setPlaceholderText('JSON array of tags, e.g. [["t","nostr"]]')
- self._content = MarkdownBodyEditor()
+ self._content = MarkdownBodyEditor(db=db)
self._hint = QLabel("Suggestions: " + ", ".join(f'["{t}","…"]' for t in TAG_SUGGESTIONS[:4]))
buttons = QDialogButtonBox(
@@ -121,7 +123,10 @@ def open_composer_for_edit(
password: str | None,
*,
write_relays: list[str],
+ db: Database | None = None,
) -> None:
clone = {k: ev[k] for k in ("kind", "tags", "content") if k in ev}
- dlg = ComposerDialog(parent, edit_from=clone, account=account, password=password, write_relays=write_relays)
+ dlg = ComposerDialog(
+ parent, edit_from=clone, account=account, password=password, write_relays=write_relays, db=db
+ )
dlg.exec()
diff --git a/src/imwald/ui/db_admin_page.py b/src/imwald/ui/db_admin_page.py
index aebb908..bf5c8bd 100644
--- a/src/imwald/ui/db_admin_page.py
+++ b/src/imwald/ui/db_admin_page.py
@@ -93,7 +93,10 @@ class DbAdminPage(QWidget):
name = self._grid.property("current_table")
if name != "events":
return None
- cols = [self._grid.horizontalHeaderItem(i).text() for i in range(self._grid.columnCount())]
+ cols = []
+ for i in range(self._grid.columnCount()):
+ hi = self._grid.horizontalHeaderItem(i)
+ cols.append(hi.text() if hi is not None else "")
try:
ci = cols.index("id")
except ValueError:
@@ -108,7 +111,10 @@ class DbAdminPage(QWidget):
name = self._grid.property("current_table")
if name != "events":
return None
- cols = [self._grid.horizontalHeaderItem(i).text() for i in range(self._grid.columnCount())]
+ cols = []
+ for i in range(self._grid.columnCount()):
+ hi = self._grid.horizontalHeaderItem(i)
+ cols.append(hi.text() if hi is not None else "")
try:
ci = cols.index("pubkey")
except ValueError:
diff --git a/src/imwald/ui/feed_page.py b/src/imwald/ui/feed_page.py
index 9684013..4d0d463 100644
--- a/src/imwald/ui/feed_page.py
+++ b/src/imwald/ui/feed_page.py
@@ -4,10 +4,10 @@ from __future__ import annotations
import html
import json
-from typing import Any
+from typing import Any, cast
-from PySide6.QtCore import Qt
-from PySide6.QtGui import QFont, QTextOption
+from PySide6.QtCore import QEvent, QObject, Qt, QTimer
+from PySide6.QtGui import QKeyEvent, QTextOption
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
@@ -21,37 +21,34 @@ from PySide6.QtWidgets import (
QWidget,
)
+from imwald.core.author_html import feed_op_author_block_html, thread_reply_author_row_html
from imwald.core.database import Database, THREAD_REPLY_KINDS
-from imwald.core.kind0_profile import display_name_from_profile, parse_kind0_profile
+from imwald.core.kind0_profile import 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.ranker import Ranker
from imwald.ui.note_text_browser import NoteTextBrowser
+from imwald.ui.theme import BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED
FEED_KINDS = (1, 20, 21, 30023, 9802, 11)
-_FEED_DOC_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 _set_plain_height_to_content(te: QPlainTextEdit) -> None:
+ doc = te.document()
+ lay = doc.documentLayout()
+ if lay is None:
+ return
+ vw = te.viewport().width()
+ if vw < 50:
+ outer = max(te.width(), 120)
+ vw = outer - te.frameWidth() * 2 - 4
+ doc.setTextWidth(float(max(vw, 80)))
+ h = lay.documentSize().height()
+ m = te.contentsMargins()
+ margins = m.top() + m.bottom() + int(doc.documentMargin()) * 2
+ fr = te.frameWidth() * 2
+ te.setFixedHeight(int(max(h + margins + fr, 44)))
def _format_engagement_html(stats: dict[str, Any]) -> str:
@@ -82,37 +79,20 @@ def _format_engagement_html(stats: dict[str, Any]) -> str:
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}
"
+ inner = f"{head}
{em_row}
"
+ else:
+ inner = f"{head}
"
+ return f'{inner}
'
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: 16px; color: #2a241c;
- }
- """
- )
self._db = db
self._engine = engine
self._ranker = Ranker(db)
+ self._page_nav_widgets: set[QObject] = set()
self._queue: list[dict[str, Any]] = []
self._index = 0
self._my_pubkey: str | None = None
@@ -127,6 +107,8 @@ class FeedPage(QWidget):
self._engagement_label.setTextFormat(Qt.TextFormat.RichText)
self._engagement_label.setWordWrap(True)
eng_layout.addWidget(self._engagement_label)
+ self._engagement_label.installEventFilter(self)
+ self._page_nav_widgets.add(self._engagement_label)
self._op_card = QFrame()
self._op_card.setObjectName("OpCard")
@@ -137,10 +119,12 @@ class FeedPage(QWidget):
self._op.setOpenExternalLinks(True)
self._op.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._op.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+ self._op.installEventFilter(self)
+ self._page_nav_widgets.add(self._op)
op_card_lay.addWidget(self._op, stretch=1)
self._why = QLabel("")
- self._why.setStyleSheet("color: #6b5f4f; font-size: 14px;")
+ self._why.setStyleSheet(f"color: {TEXT_MUTED}; font-size: 14px;")
self._why.setWordWrap(True)
self._thread_title = QLabel(
@@ -156,6 +140,7 @@ class FeedPage(QWidget):
self._thread_scroll.setWidgetResizable(True)
self._thread_scroll.setWidget(self._thread_host)
self._thread_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self._thread_scroll.viewport().installEventFilter(self)
prev = QPushButton("◀ Previous")
next_ = QPushButton("Next ▶")
@@ -173,6 +158,9 @@ class FeedPage(QWidget):
nav.addWidget(down)
nav.addStretch()
nav.addWidget(self._why)
+ for w in (prev, next_, up, down, self._why):
+ w.installEventFilter(self)
+ self._page_nav_widgets.add(w)
left = QVBoxLayout()
left.setSpacing(8)
@@ -192,6 +180,9 @@ class FeedPage(QWidget):
split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(lw)
split.addWidget(rw)
+ for w in (self._engagement, self._engagement_label, self._op_card, self._thread_title, lw, rw, split):
+ w.installEventFilter(self)
+ self._page_nav_widgets.add(w)
split.setStretchFactor(0, 3)
split.setStretchFactor(1, 2)
split.setSizes([780, 420])
@@ -200,6 +191,34 @@ class FeedPage(QWidget):
outer.setContentsMargins(10, 8, 10, 8)
outer.addWidget(split)
+ def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802
+ if event.type() == QEvent.Type.KeyPress and isinstance(event, QKeyEvent):
+ nav_ok = obj in self._page_nav_widgets or (
+ isinstance(obj, QPlainTextEdit) and obj.objectName() == "ReplyBody"
+ )
+ if nav_ok:
+ if event.key() == Qt.Key.Key_PageDown:
+ self._next()
+ return True
+ if event.key() == Qt.Key.Key_PageUp:
+ self._prev()
+ return True
+ if obj is self._thread_scroll.viewport() and event.type() == QEvent.Type.Resize:
+ self._refit_thread_reply_sizes()
+ return super().eventFilter(obj, event)
+
+ def _refit_thread_reply_sizes(self) -> None:
+ for i in range(self._thread_layout.count()):
+ item = self._thread_layout.itemAt(i)
+ if item is None:
+ continue
+ w = item.widget()
+ if w is None:
+ continue
+ te = w.findChild(QPlainTextEdit, "ReplyBody")
+ if te is not None:
+ _set_plain_height_to_content(te)
+
def set_context(
self,
my_pubkey: str | None,
@@ -237,7 +256,7 @@ class FeedPage(QWidget):
if not ev:
self._op.setPlainText(f"(not in local DB yet) {event_id}")
return
- self._queue = [ev]
+ self._queue = [cast(dict[str, Any], ev)]
self._index = 0
self._show_current()
if not ev.get("deleted"):
@@ -263,8 +282,10 @@ class FeedPage(QWidget):
if ev.get("deleted"):
raw = html.escape(ev.get("content") or "")
self._op.setHtml(
- f"Marked deleted locally
{raw}"
- f"{html.escape(ev['id'])}
"
+ f""
+ f"Marked deleted locally
"
+ f"{raw}"
+ f"{html.escape(ev['id'])}
"
)
self._clear_thread_rows()
self._why.setText("")
@@ -284,41 +305,42 @@ class FeedPage(QWidget):
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))
+ parsed = parse_kind0_profile(prof_row["content"] if prof_row else "")
npub = encode_npub(pk)
- npub_e = html.escape(npub)
- pk_short = html.escape(pk[:12] + "…")
- pic_url = _safe_http_url(parsed.get("picture"))
+ pk_short = pk[:12] + "…"
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 ""
+ author_block = feed_op_author_block_html(
+ parsed,
+ npub,
+ pk_short,
+ nip_line,
+ about_line,
+ text=TEXT,
+ muted=TEXT_MUTED,
+ dim=TEXT_DIM,
+ border=BORDER,
)
- 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)
"
+ tr = f"Trending slice (nostrarchives)
"
eid = html.escape(ev["id"])
- md_body = markdown_html_fragment(ev.get("content") or "")
+ md_body = markdown_html_fragment(ev.get("content") or "", db=self._db)
body = (
""
- 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"{FEED_DOC_CSS}"
+ f"{author_block}"
+ f"Kind {int(ev['kind'])} · {int(ev['created_at'])}
"
f"{tr}"
f"{md_body}
"
- f"{eid}
"
+ f"{eid}
"
""
)
self._op.setHtml(body)
@@ -329,41 +351,49 @@ class FeedPage(QWidget):
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 "")
+ pr = profiles.get(rpk)
+ rp = parse_kind0_profile(pr["content"] if pr else "")
+ plain = markdown_to_plain_text(r.get("content") or "", db=self._db)
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_b = NoteTextBrowser(self)
+ head_b.setObjectName("ReplyHead")
+ head_b.setOpenExternalLinks(False)
+ head_b.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ head_b.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ head_b.setFrameShape(QFrame.Shape.NoFrame)
+ head_b.document().setDocumentMargin(2)
+ head_b.setFixedHeight(56)
+ row_html = thread_reply_author_row_html(
+ rp, rk, encode_npub(rpk), text=TEXT, muted=TEXT_MUTED, dim=TEXT_DIM, border=BORDER
+ )
+ head_b.setHtml(
+ ""
+ f"{FEED_DOC_CSS}"
+ f"{row_html}"
)
- head.setTextFormat(Qt.TextFormat.RichText)
- head.setWordWrap(True)
- f_small = QFont()
- f_small.setPointSize(13)
- head.setFont(f_small)
+ head_b.installEventFilter(self)
+ self._page_nav_widgets.add(head_b)
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.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
body_te.setFrameShape(QFrame.Shape.NoFrame)
body_te.document().setDocumentMargin(0)
- body_te.setMinimumHeight(96)
- body_te.setMaximumHeight(440)
- vl.addWidget(head)
+ body_te.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ body_te.installEventFilter(self)
+ vl.addWidget(head_b)
vl.addWidget(body_te)
self._thread_layout.addWidget(card)
self._thread_layout.addStretch(1)
+ QTimer.singleShot(0, self._refit_thread_reply_sizes)
def _prev(self) -> None:
if self._queue:
diff --git a/src/imwald/ui/main_window.py b/src/imwald/ui/main_window.py
index 26c38c2..7144eb0 100644
--- a/src/imwald/ui/main_window.py
+++ b/src/imwald/ui/main_window.py
@@ -236,7 +236,7 @@ class MainWindow(QMainWindow):
QMessageBox.information(self, "Edit", "Select an account.")
return
writes = resolve_for_account(self._db, acc.pubkey).write_urls
- dlg = ComposerDialog(self, edit_from=ev, account=acc, password=pw, write_relays=writes)
+ dlg = ComposerDialog(self, edit_from=ev, account=acc, password=pw, write_relays=writes, db=self._db)
if dlg.exec() == QDialog.DialogCode.Accepted and dlg.last_published:
self._db.upsert_event(dlg.last_published)
@@ -246,7 +246,7 @@ class MainWindow(QMainWindow):
QMessageBox.information(self, "Composer", "Select an account or add keys via onboarding.")
return
writes = resolve_for_account(self._db, acc.pubkey).write_urls
- dlg = ComposerDialog(self, edit_from=None, account=acc, password=pw, write_relays=writes)
+ dlg = ComposerDialog(self, edit_from=None, account=acc, password=pw, write_relays=writes, db=self._db)
if dlg.exec() == QDialog.DialogCode.Accepted and dlg.last_published:
self._db.upsert_event(dlg.last_published)
diff --git a/src/imwald/ui/markdown_editor_widget.py b/src/imwald/ui/markdown_editor_widget.py
index 9049b84..5b905c3 100644
--- a/src/imwald/ui/markdown_editor_widget.py
+++ b/src/imwald/ui/markdown_editor_widget.py
@@ -6,14 +6,16 @@ from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QApplication, QPlainTextEdit, QSizePolicy, QSplitter, QTextBrowser, QVBoxLayout, QWidget
+from imwald.core.database import Database
from imwald.core.md_render import markdown_html_document
class MarkdownBodyEditor(QWidget):
"""Plain-text Markdown editor with live rendered preview (local ``marked`` + nh3)."""
- def __init__(self, parent: QWidget | None = None) -> None:
+ def __init__(self, parent: QWidget | None = None, *, db: Database | None = None) -> None:
super().__init__(parent)
+ self._db = db
self._split = QSplitter(Qt.Orientation.Horizontal)
self._source = QPlainTextEdit()
self._source.setPlaceholderText("Markdown source — preview updates as you type")
@@ -52,7 +54,7 @@ class MarkdownBodyEditor(QWidget):
self._update_preview()
def _update_preview(self) -> None:
- self._preview.setHtml(markdown_html_document(self._source.toPlainText()))
+ self._preview.setHtml(markdown_html_document(self._source.toPlainText(), db=self._db))
def setPlainText(self, text: str) -> None:
self._source.setPlainText(text)
diff --git a/src/imwald/ui/note_text_browser.py b/src/imwald/ui/note_text_browser.py
index 323f503..00a9552 100644
--- a/src/imwald/ui/note_text_browser.py
+++ b/src/imwald/ui/note_text_browser.py
@@ -16,7 +16,7 @@ _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]
+ def loadResource(self, rtype: int, name: QUrl) -> QByteArray: # type: ignore[override]
if rtype == QTextDocument.ResourceType.ImageResource:
u = name.toString()
if u.startswith(("https://", "http://")):
diff --git a/src/imwald/ui/theme.py b/src/imwald/ui/theme.py
new file mode 100644
index 0000000..d25b221
--- /dev/null
+++ b/src/imwald/ui/theme.py
@@ -0,0 +1,239 @@
+"""Dark UI palette with green accents; applied globally via ``QApplication`` stylesheet."""
+
+from __future__ import annotations
+
+from PySide6.QtWidgets import QApplication
+
+# Shared with HTML fragments (feed header, markdown preview).
+TEXT = "#dceee6"
+TEXT_MUTED = "#8fb0a3"
+TEXT_DIM = "#6a8578"
+ACCENT = "#45e0a8"
+ACCENT_SOFT = "#2a9d6f"
+LINK = "#5eead4"
+BG_WINDOW = "#0d1210"
+BG_FIELD = "#111916"
+BG_CARD = "#151f1a"
+BORDER = "#2a3d34"
+BG_CODE = "#0a100d"
+
+FEED_DOC_CSS = f"""
+
+"""
+
+APPLICATION_QSS = f"""
+QWidget {{ background-color: {BG_WINDOW}; color: {TEXT}; }}
+QMainWindow {{ background-color: {BG_WINDOW}; }}
+QMenuBar {{
+ background-color: {BG_FIELD};
+ color: {TEXT};
+ border-bottom: 1px solid {BORDER};
+ padding: 2px;
+}}
+QMenuBar::item:selected {{ background-color: {BG_CARD}; }}
+QMenu {{
+ background-color: {BG_FIELD};
+ color: {TEXT};
+ border: 1px solid {BORDER};
+ padding: 4px;
+}}
+QMenu::item:selected {{ background-color: {BG_CARD}; color: {ACCENT}; }}
+QToolBar {{
+ background-color: {BG_FIELD};
+ border-bottom: 1px solid {BORDER};
+ spacing: 8px;
+ padding: 4px;
+}}
+QStatusBar {{
+ background-color: {BG_FIELD};
+ color: {TEXT_MUTED};
+ border-top: 1px solid {BORDER};
+}}
+QPushButton {{
+ background-color: {BG_CARD};
+ color: {TEXT};
+ border: 1px solid {BORDER};
+ padding: 6px 14px;
+ border-radius: 8px;
+}}
+QPushButton:hover {{ border-color: {ACCENT}; color: {ACCENT}; }}
+QPushButton:pressed {{ background-color: {BG_CODE}; }}
+QLineEdit, QSpinBox {{
+ background-color: {BG_FIELD};
+ color: {TEXT};
+ border: 1px solid {BORDER};
+ border-radius: 8px;
+ padding: 6px 8px;
+}}
+QLineEdit:focus, QSpinBox:focus {{ border-color: {ACCENT}; }}
+QPlainTextEdit, QTextEdit {{
+ background-color: {BG_FIELD};
+ color: {TEXT};
+ border: 1px solid {BORDER};
+ border-radius: 8px;
+ padding: 6px;
+}}
+QPlainTextEdit:focus, QTextEdit:focus {{ border-color: {ACCENT}; }}
+QTextBrowser {{
+ background-color: {BG_FIELD};
+ color: {TEXT};
+ border: 1px solid {BORDER};
+ border-radius: 8px;
+ padding: 4px;
+}}
+QListWidget, QTableWidget {{
+ background-color: {BG_FIELD};
+ color: {TEXT};
+ border: 1px solid {BORDER};
+ border-radius: 8px;
+}}
+QListWidget::item:selected, QTableWidget::item:selected {{
+ background-color: {BG_CARD};
+ color: {ACCENT};
+}}
+QHeaderView::section {{
+ background-color: {BG_CARD};
+ color: {TEXT_MUTED};
+ padding: 6px;
+ border: 1px solid {BORDER};
+}}
+QTabWidget::pane {{
+ border: 1px solid {BORDER};
+ border-radius: 8px;
+ background-color: {BG_FIELD};
+ top: -1px;
+}}
+QTabBar::tab {{
+ background-color: {BG_CARD};
+ color: {TEXT_MUTED};
+ padding: 8px 16px;
+ margin-right: 2px;
+ border: 1px solid {BORDER};
+ border-bottom: none;
+ border-top-left-radius: 8px;
+ border-top-right-radius: 8px;
+}}
+QTabBar::tab:selected {{
+ background-color: {BG_FIELD};
+ color: {ACCENT};
+ font-weight: 600;
+}}
+QComboBox {{
+ background-color: {BG_FIELD};
+ color: {TEXT};
+ border: 1px solid {BORDER};
+ border-radius: 8px;
+ padding: 4px 10px;
+ min-height: 1.2em;
+}}
+QComboBox:hover {{ border-color: {ACCENT}; }}
+QComboBox::drop-down {{ border: none; width: 22px; }}
+QComboBox QAbstractItemView {{
+ background-color: {BG_FIELD};
+ color: {TEXT};
+ selection-background-color: {BG_CARD};
+ selection-color: {ACCENT};
+}}
+QScrollArea {{ border: none; background: transparent; }}
+QScrollBar:vertical {{
+ background: {BG_WINDOW};
+ width: 12px;
+ margin: 0;
+}}
+QScrollBar::handle:vertical {{
+ background: {BORDER};
+ min-height: 28px;
+ border-radius: 5px;
+}}
+QScrollBar::handle:vertical:hover {{ background: {ACCENT_SOFT}; }}
+QScrollBar:horizontal {{
+ background: {BG_WINDOW};
+ height: 12px;
+ margin: 0;
+}}
+QScrollBar::handle:horizontal {{
+ background: {BORDER};
+ min-width: 28px;
+ border-radius: 5px;
+}}
+QScrollBar::handle:horizontal:hover {{ background: {ACCENT_SOFT}; }}
+QSplitter::handle {{ background: {BORDER}; width: 3px; }}
+QSplitter::handle:horizontal {{ width: 3px; }}
+QSplitter::handle:vertical {{ height: 3px; }}
+QDialog {{ background-color: {BG_WINDOW}; }}
+QDialogButtonBox QPushButton {{ min-width: 72px; }}
+QLabel {{ color: {TEXT}; }}
+QCheckBox {{ color: {TEXT}; spacing: 8px; }}
+QCheckBox::indicator {{
+ width: 18px;
+ height: 18px;
+ border: 1px solid {BORDER};
+ border-radius: 4px;
+ background: {BG_FIELD};
+}}
+QCheckBox::indicator:checked {{
+ background: {BG_CARD};
+ border-color: {ACCENT};
+}}
+QWizard {{ background-color: {BG_WINDOW}; }}
+QWizardPage {{ background-color: {BG_WINDOW}; }}
+QWidget#FeedPage {{ background-color: {BG_WINDOW}; }}
+QFrame#EngagementBar {{
+ background-color: {BG_CARD};
+ border: 1px solid {BORDER};
+ border-radius: 10px;
+ padding: 8px 12px;
+}}
+QFrame#OpCard {{
+ background-color: {BG_FIELD};
+ border: 1px solid {BORDER};
+ border-radius: 12px;
+}}
+QScrollArea#ThreadScroll {{
+ border: 1px solid {BORDER};
+ border-radius: 10px;
+ background-color: {BG_CARD};
+}}
+QFrame#ReplyCard {{
+ background-color: {BG_FIELD};
+ border: 1px solid {BORDER};
+ border-radius: 8px;
+ margin: 2px 0;
+}}
+QLabel#ThreadTitle {{
+ font-weight: 600;
+ color: {ACCENT};
+ padding: 4px 2px;
+}}
+QPlainTextEdit#ReplyBody {{
+ border: none;
+ background: transparent;
+ font-size: 16px;
+ color: {TEXT};
+}}
+QTextBrowser#ReplyHead {{
+ background: transparent;
+ border: none;
+ max-height: 88px;
+ padding: 0;
+}}
+QTextBrowser#OpNote {{
+ background-color: transparent;
+ color: {TEXT};
+ border: none;
+}}
+"""
+
+
+def apply_application_theme(app: QApplication) -> None:
+ app.setStyle("Fusion")
+ app.setStyleSheet(APPLICATION_QSS)
diff --git a/tests/test_nostr_entity_render.py b/tests/test_nostr_entity_render.py
new file mode 100644
index 0000000..c7df638
--- /dev/null
+++ b/tests/test_nostr_entity_render.py
@@ -0,0 +1,85 @@
+import json
+import tempfile
+import time
+from pathlib import Path
+
+import bech32
+
+from imwald.core.database import Database
+from imwald.core.md_render import markdown_html_fragment
+from imwald.core.nip19 import decode_nip19_entity, encode_npub
+
+
+def _note_bech32(event_id_hex: str) -> str:
+ raw = bytes.fromhex(event_id_hex)
+ conv = bech32.convertbits(list(raw), 8, 5, True)
+ assert conv is not None
+ return bech32.bech32_encode("note", conv)
+
+
+def test_decode_nip19_npub_roundtrip() -> None:
+ pk = "11" * 32
+ n = encode_npub(pk)
+ dec = decode_nip19_entity(n)
+ assert dec == {"hrp": "npub", "pubkey": pk}
+
+
+def test_decode_nip19_note_roundtrip() -> None:
+ eid = "22" * 32
+ n = _note_bech32(eid)
+ dec = decode_nip19_entity(n)
+ assert dec == {"hrp": "note", "event_id": eid}
+
+
+def test_markdown_renders_nostr_npub_badge_with_db() -> None:
+ pk = "33" * 32
+ npub = encode_npub(pk)
+ with tempfile.TemporaryDirectory() as td:
+ db = Database(Path(td) / "t.sqlite")
+ db.connect()
+ db.upsert_event(
+ {
+ "id": "44" * 32,
+ "pubkey": pk,
+ "created_at": int(time.time()),
+ "kind": 0,
+ "content": json.dumps(
+ {
+ "name": "River",
+ "display_name": "River Tam",
+ "picture": "https://example.com/avatar.png",
+ }
+ ),
+ "sig": "aa" * 64,
+ "tags": [],
+ }
+ )
+ html = markdown_html_fragment(f"Hi nostr:{npub} bye", db=db)
+ assert "nostr-user-badge" in html
+ assert "@River Tam" in html or "River Tam" in html
+ assert "Hi" in html and "bye" in html
+ assert "example.com" in html and "avatar.png" in html
+
+
+def test_markdown_renders_nostr_note_embed() -> None:
+ pk = "66" * 32
+ eid = "77" * 32
+ note = _note_bech32(eid)
+ with tempfile.TemporaryDirectory() as td:
+ db = Database(Path(td) / "t.sqlite")
+ db.connect()
+ db.upsert_event(
+ {
+ "id": eid,
+ "pubkey": pk,
+ "created_at": 1700000000,
+ "kind": 1,
+ "content": "embedded **note** body",
+ "sig": "bb" * 64,
+ "tags": [],
+ }
+ )
+ html = markdown_html_fragment(f"Ref nostr:{note}", db=db)
+ assert "nostr-embed" in html
+ assert "embedded" in html
+ assert "kind 1" in html