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"", "\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