Browse Source

change to dark theme

handle nostr addresses
master
Silberengel 2 weeks ago
parent
commit
1ea1b0a192
  1. 5
      pyproject.toml
  2. 2
      src/imwald/app.py
  3. 116
      src/imwald/core/author_html.py
  4. 61
      src/imwald/core/database.py
  5. 94
      src/imwald/core/md_render.py
  6. 113
      src/imwald/core/nip19.py
  7. 8
      src/imwald/core/nostr_crypto.py
  8. 141
      src/imwald/core/nostr_entity_render.py
  9. 2
      src/imwald/core/ranker.py
  10. 4
      src/imwald/core/relay_manager.py
  11. 13
      src/imwald/ui/composer_dialog.py
  12. 10
      src/imwald/ui/db_admin_page.py
  13. 208
      src/imwald/ui/feed_page.py
  14. 4
      src/imwald/ui/main_window.py
  15. 6
      src/imwald/ui/markdown_editor_widget.py
  16. 2
      src/imwald/ui/note_text_browser.py
  17. 239
      src/imwald/ui/theme.py
  18. 85
      tests/test_nostr_entity_render.py

5
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. # So third-party stubs (e.g. Pillow → ``PIL``) resolve when using ``.venv`` at the repo root.
venvPath = "." venvPath = "."
venv = ".venv" 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"

2
src/imwald/app.py

@ -12,6 +12,7 @@ from imwald.config import db_path
from imwald.core.database import Database from imwald.core.database import Database
from imwald.core.nostr_engine import NostrEngine from imwald.core.nostr_engine import NostrEngine
from imwald.ui.main_window import MainWindow from imwald.ui.main_window import MainWindow
from imwald.ui.theme import apply_application_theme
def _set_comfortable_default_font(app: QApplication) -> None: def _set_comfortable_default_font(app: QApplication) -> None:
@ -34,6 +35,7 @@ def main() -> None:
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("imwald") app.setApplicationName("imwald")
app.setOrganizationName("imwald") app.setOrganizationName("imwald")
apply_application_theme(app)
_set_comfortable_default_font(app) _set_comfortable_default_font(app)
db = Database(db_path()) db = Database(db_path())

116
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'<img src="{pic}" width="{size_px}" height="{size_px}" alt="" '
f'style="border-radius:{r}px;object-fit:cover;vertical-align:middle;flex-shrink:0">'
)
return (
f'<span style="display:inline-block;width:{size_px}px;height:{size_px}px;'
f"border-radius:{r}px;background:{border_hex};"
f'flex-shrink:0;vertical-align:middle"></span>'
)
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'<div style="display:flex;align-items:flex-start;margin-bottom:12px">'
f"{av}"
f'<div style="flex:1;min-width:0">'
f'<div style="font-size:21px;font-weight:600;color:{text}">{disp}</div>'
f'<div style="color:{muted};font-size:15px">{npub_e} · {pk_s}</div>'
f"{nip_line_html}{about_line_html}"
f"</div></div>"
)
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 (
'<div style="display:flex;align-items:center;gap:10px;margin:0 0 6px 0">'
f"{av}"
'<div style="flex:1;min-width:0;line-height:1.35">'
f'<span style="color:{dim};font-size:13px">k{int(kind)}</span> &nbsp; '
f'<b style="color:{text};font-size:15px">{name}</b> &nbsp; '
f'<span style="color:{muted};font-size:13px">{npub_e}</span>'
"</div></div>"
)
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'<img src="{pic}" width="20" height="20" alt="" '
'style="border-radius:50%;object-fit:cover;margin-right:6px;vertical-align:middle;flex-shrink:0">'
)
inner = f"{img}<span style=\"font-weight:600\">@{name}</span>"
return f'<span class="nostr-user-badge" style="{badge_style}" title="{tip}">{inner}</span>'
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 (
'<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">'
f"{av}"
f'<div style="flex:1;min-width:0;font-weight:600;color:#5eead4;font-size:0.92em;line-height:1.3">'
f"{head_line_html}"
"</div></div>"
)

61
src/imwald/core/database.py

@ -7,10 +7,27 @@ import sqlite3
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Any, Generator, Iterable from typing import Any, Generator, Iterable, TypedDict, cast
SCHEMA_VERSION = 2 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). # 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) THREAD_REPLY_KINDS: tuple[int, ...] = (1, 16, 1111, 1244)
@ -310,7 +327,7 @@ class Database:
"tags": json.loads(row["tags_json"] or "[]"), "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( cur = self.conn().execute(
"SELECT id,pubkey,created_at,kind,content,sig,tags_json,deleted,source_relay FROM events WHERE id=?", "SELECT id,pubkey,created_at,kind,content,sig,tags_json,deleted,source_relay FROM events WHERE id=?",
(event_id,), (event_id,),
@ -321,11 +338,39 @@ class Database:
return { return {
"id": row["id"], "id": row["id"],
"pubkey": row["pubkey"], "pubkey": row["pubkey"],
"created_at": row["created_at"], "created_at": int(row["created_at"]),
"kind": row["kind"], "kind": int(row["kind"]),
"content": row["content"], "content": row["content"],
"sig": row["sig"], "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"]), "deleted": bool(row["deleted"]),
"source_relay": row["source_relay"], "source_relay": row["source_relay"],
} }
@ -537,7 +582,7 @@ class Database:
row = cur.fetchone() row = cur.fetchone()
return int(row["vote"]) if row else None return int(row["vote"]) if row else None
def get_latest_kind0_profile(self, pubkey: str) -> dict[str, Any] | None: def get_latest_kind0_profile(self, pubkey: str) -> Kind0ProfileSummary | None:
"""Latest non-deleted kind-0 for ``pubkey`` (hex), or None.""" """Latest non-deleted kind-0 for ``pubkey`` (hex), or None."""
cur = self.conn().execute( cur = self.conn().execute(
""" """
@ -552,7 +597,7 @@ class Database:
return None return None
return {"content": row["content"] or "", "created_at": int(row["created_at"])} 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).""" """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] pks = [p.lower() for p in pubkeys if isinstance(p, str) and len(p) == 64]
if not pks: if not pks:
@ -571,7 +616,7 @@ class Database:
""", """,
pks, pks,
) )
out: dict[str, dict[str, Any]] = {} out: dict[str, Kind0ProfileSummary] = {}
for row in cur: for row in cur:
pk = str(row["pk"]) pk = str(row["pk"])
out[pk] = {"content": row["content"] or "", "created_at": int(row["created_at"])} out[pk] = {"content": row["content"] or "", "created_at": int(row["created_at"])}

94
src/imwald/core/md_render.py

@ -6,10 +6,18 @@ import html
import json import json
import logging import logging
import re import re
from copy import deepcopy
from collections.abc import MutableMapping
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, cast
import nh3 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__) log = logging.getLogger(__name__)
# Bare HTTPS image URLs in notes → Markdown image (so renderer emits ``<img>``). # Bare HTTPS image URLs in notes → Markdown image (so renderer emits ``<img>``).
@ -22,6 +30,62 @@ _MARKED_PATH = Path(__file__).resolve().parents[1] / "ui" / "assets" / "vendor"
_qjs_ctx = None _qjs_ctx = None
_marked_load_failed = False _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(): def _marked_quickjs_ctx():
"""Singleton QuickJS context with ``marked`` loaded, or None if unavailable.""" """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 "") 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.""" """Sanitized HTML fragment (body inner HTML) for embedding in templates."""
md = preprocess_standalone_image_urls(md) # Standalone image URLs first: nostr preprocessing injects ``<img src="https://…">``; 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) raw = _render_marked_js(md)
if raw is None: if raw is None:
raw = _render_markdown_fallback(md) 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``, 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). 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. # Cap source length so list views (search, notifications, threads) do not parse huge notes.
src = (md or "")[:1200] src = (md or "")[:1200]
frag = markdown_html_fragment(src) frag = markdown_html_fragment(src, db=db)
plain = html.unescape(re.sub(r"<[^>]+>", " ", frag)) plain = html.unescape(re.sub(r"<[^>]+>", " ", frag))
plain = re.sub(r"\s+", " ", plain).strip() plain = re.sub(r"\s+", " ", plain).strip()
if len(plain) <= max_len: 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] + "" 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.""" """Full plain text from Markdown (for thread bodies); keeps paragraph breaks."""
src = (md or "")[:max_source] src = (md or "")[:max_source]
frag = markdown_html_fragment(src) frag = markdown_html_fragment(src, db=db)
frag = re.sub(r"<br\s*/?>", "\n", frag, flags=re.I) frag = re.sub(r"<br\s*/?>", "\n", frag, flags=re.I)
frag = re.sub(r"</p\s*>", "\n\n", frag, flags=re.I) frag = re.sub(r"</p\s*>", "\n\n", frag, flags=re.I)
frag = re.sub(r"</(div|blockquote|h[1-6]|li|tr)\s*>", "\n", frag, flags=re.I) 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 = """<style> _PREVIEW_CSS = """<style>
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:17px;margin:0;padding:12px;line-height:1.45;color:#1a1a1a;} body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:17px;margin:0;padding:12px;line-height:1.45;color:#dceee6;}
pre,code{font-family:ui-monospace,"Cascadia Code","Consolas",monospace;font-size:15px;} pre,code{font-family:ui-monospace,"Cascadia Code","Consolas",monospace;font-size:15px;}
pre{background:#f4f4f4;padding:10px;border-radius:6px;overflow-x:auto;} pre{background:#0a100d;color:#dceee6;padding:10px;border-radius:6px;overflow-x:auto;}
blockquote{border-left:3px solid #bbb;margin:8px 0;padding:4px 0 4px 12px;color:#444;} blockquote{border-left:3px solid #2a9d6f;margin:8px 0;padding:4px 0 4px 12px;color:#8fb0a3;}
a{color:#5eead4;}
table{border-collapse:collapse;margin:8px 0;width:100%;} table{border-collapse:collapse;margin:8px 0;width:100%;}
th,td{border:1px solid #ccc;padding:6px;} th,td{border:1px solid #2a3d34;padding:6px;}
img{max-width:min(100%,400px);height:auto;} img{max-width:min(100%,400px);height:auto;}
</style>""" </style>"""
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.""" """Full HTML document for ``QTextBrowser`` preview panes."""
inner = markdown_html_fragment(md) inner = markdown_html_fragment(md, db=db)
return ( return (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">" "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{_PREVIEW_CSS}</head><body>{inner}</body></html>" f"{_PREVIEW_CSS}</head><body>{inner}</body></html>"

113
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 __future__ import annotations
from typing import Literal, NotRequired, TypedDict
import bech32 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: def decode_nsec(nsec: str) -> bytes:
hrp, data = bech32.bech32_decode(nsec.strip()) hrp, data = bech32.bech32_decode(nsec.strip())
if hrp != "nsec" or data is None: if hrp != "nsec" or data is None:
@ -32,3 +65,81 @@ def encode_npub(pubkey_hex: str) -> str:
if conv is None: if conv is None:
raise ValueError("invalid pubkey encoding") raise ValueError("invalid pubkey encoding")
return bech32.bech32_encode("npub", conv) 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

8
src/imwald/core/nostr_crypto.py

@ -10,12 +10,14 @@ from coincurve import PrivateKey
from coincurve.keys import PublicKeyXOnly 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] arr = [0, pubkey, created_at, kind, tags, content]
return json.dumps(arr, ensure_ascii=False, separators=(",", ":")) 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) ser = serialize_event_for_id(pubkey, created_at, kind, tags, content)
return sha256(ser.encode("utf-8")).hexdigest() return sha256(ser.encode("utf-8")).hexdigest()
@ -74,7 +76,7 @@ def build_signed_event(
*, *,
created_at: int, created_at: int,
kind: int, kind: int,
tags: list, tags: list[list[str]],
content: str, content: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
pubkey = pubkey_hex_from_secret(secret) pubkey = pubkey_hex_from_secret(secret)

141
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'<div class="nostr-embed nostr-embed-missing" style="{_EMBED_STYLE}">'
f"<i>Event not in local database yet</i> · <code>{short}</code></div>\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'<div class="nostr-embed" style="{_EMBED_STYLE}">'
f"{head_row}"
f'<div style="color:#dceee6;line-height:1.45;white-space:pre-wrap">{body}</div>'
"</div>\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'<div class="nostr-embed nostr-embed-missing" style="{_EMBED_STYLE}">'
f"<i>Addressable event not in local database yet</i> · {hint}</div>\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)

2
src/imwald/core/ranker.py

@ -22,7 +22,7 @@ WEIGHT_ZAP = 3.0
WEIGHT_LOCAL_VOTE = 1.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: for t in tags:
if not t: if not t:
continue continue

4
src/imwald/core/relay_manager.py

@ -13,7 +13,7 @@ from enum import Enum
from typing import Any, Callable, Coroutine from typing import Any, Callable, Coroutine
import websockets import websockets
from websockets.client import WebSocketClientProtocol from websockets.asyncio.client import ClientConnection
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -33,7 +33,7 @@ class RelayConn:
last_error: str | None = None last_error: str | None = None
last_connected_at: float | None = None last_connected_at: float | None = None
backoff_until: float = 0.0 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) _task: asyncio.Task[None] | None = field(default=None, repr=False)
def status_line(self) -> str: def status_line(self) -> str:

13
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.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_crypto import build_signed_event
from imwald.core.nostr_publish import publish_to_relays_sync from imwald.core.nostr_publish import publish_to_relays_sync
from imwald.ui.markdown_editor_widget import MarkdownBodyEditor from imwald.ui.markdown_editor_widget import MarkdownBodyEditor
@ -33,10 +34,11 @@ class ComposerDialog(QDialog):
self, self,
parent=None, parent=None,
*, *,
edit_from: dict[str, Any] | None = None, edit_from: StoredEventRow | dict[str, Any] | None = None,
account: StoredAccount, account: StoredAccount,
password: str | None = None, password: str | None = None,
write_relays: list[str], write_relays: list[str],
db: Database | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("New event" if edit_from is None else "Edit event (clone)") self.setWindowTitle("New event" if edit_from is None else "Edit event (clone)")
@ -45,7 +47,7 @@ class ComposerDialog(QDialog):
self._password = password self._password = password
self._edit_from = edit_from self._edit_from = edit_from
self._write_relays = list(write_relays) 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 = QSpinBox()
self._kind.setRange(0, 99999) self._kind.setRange(0, 99999)
@ -57,7 +59,7 @@ class ComposerDialog(QDialog):
self._tags = QLineEdit() self._tags = QLineEdit()
self._tags.setPlaceholderText('JSON array of tags, e.g. [["t","nostr"]]') 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])) self._hint = QLabel("Suggestions: " + ", ".join(f'["{t}",""]' for t in TAG_SUGGESTIONS[:4]))
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
@ -121,7 +123,10 @@ def open_composer_for_edit(
password: str | None, password: str | None,
*, *,
write_relays: list[str], write_relays: list[str],
db: Database | None = None,
) -> None: ) -> None:
clone = {k: ev[k] for k in ("kind", "tags", "content") if k in ev} 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() dlg.exec()

10
src/imwald/ui/db_admin_page.py

@ -93,7 +93,10 @@ class DbAdminPage(QWidget):
name = self._grid.property("current_table") name = self._grid.property("current_table")
if name != "events": if name != "events":
return None 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: try:
ci = cols.index("id") ci = cols.index("id")
except ValueError: except ValueError:
@ -108,7 +111,10 @@ class DbAdminPage(QWidget):
name = self._grid.property("current_table") name = self._grid.property("current_table")
if name != "events": if name != "events":
return None 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: try:
ci = cols.index("pubkey") ci = cols.index("pubkey")
except ValueError: except ValueError:

208
src/imwald/ui/feed_page.py

@ -4,10 +4,10 @@ from __future__ import annotations
import html import html
import json import json
from typing import Any from typing import Any, cast
from PySide6.QtCore import Qt from PySide6.QtCore import QEvent, QObject, Qt, QTimer
from PySide6.QtGui import QFont, QTextOption from PySide6.QtGui import QKeyEvent, QTextOption
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
@ -21,37 +21,34 @@ from PySide6.QtWidgets import (
QWidget, 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.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.md_render import markdown_html_fragment, markdown_to_plain_text
from imwald.core.nip19 import encode_npub from imwald.core.nip19 import encode_npub
from imwald.core.nostr_engine import NostrEngine from imwald.core.nostr_engine import NostrEngine
from imwald.core.ranker import Ranker from imwald.core.ranker import Ranker
from imwald.ui.note_text_browser import NoteTextBrowser 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_KINDS = (1, 20, 21, 30023, 9802, 11)
_FEED_DOC_CSS = """
<style> def _set_plain_height_to_content(te: QPlainTextEdit) -> None:
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 17px; doc = te.document()
margin: 0; padding: 0; line-height: 1.5; color: #1e1b16; background: transparent; } lay = doc.documentLayout()
a { color: #2563eb; } if lay is None:
pre, code { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 15px; } return
pre { background: #f0ebe3; padding: 10px; border-radius: 8px; overflow-x: auto; } vw = te.viewport().width()
blockquote { border-left: 3px solid #c4b8a8; margin: 8px 0; padding: 4px 0 4px 12px; color: #4a4236; } if vw < 50:
.md img { max-width: min(100%, 400px); height: auto; border-radius: 8px; margin: 6px 0; } outer = max(te.width(), 120)
.md p { margin: 0.45em 0; } vw = outer - te.frameWidth() * 2 - 4
</style> doc.setTextWidth(float(max(vw, 80)))
""" h = lay.documentSize().height()
m = te.contentsMargins()
margins = m.top() + m.bottom() + int(doc.documentMargin()) * 2
def _safe_http_url(u: str | None) -> str | None: fr = te.frameWidth() * 2
if not u or not isinstance(u, str): te.setFixedHeight(int(max(h + margins + fr, 44)))
return None
u = u.strip()
if u.startswith("https://") or u.startswith("http://"):
return html.escape(u, quote=True)
return None
def _format_engagement_html(stats: dict[str, Any]) -> str: def _format_engagement_html(stats: dict[str, Any]) -> str:
@ -82,37 +79,20 @@ def _format_engagement_html(stats: dict[str, Any]) -> str:
em_row = " &nbsp; ".join(emoji_bits) if emoji_bits else "" em_row = " &nbsp; ".join(emoji_bits) if emoji_bits else ""
head = " &nbsp;·&nbsp; ".join(parts) if parts else "no engagement in local DB yet" head = " &nbsp;·&nbsp; ".join(parts) if parts else "no engagement in local DB yet"
if em_row: if em_row:
return f"<div style='margin-bottom:6px'>{head}</div><div>{em_row}</div>" inner = f"<div style='margin-bottom:6px'>{head}</div><div>{em_row}</div>"
return f"<div>{head}</div>" else:
inner = f"<div>{head}</div>"
return f'<div style="color:{TEXT}">{inner}</div>'
class FeedPage(QWidget): class FeedPage(QWidget):
def __init__(self, db: Database, engine: NostrEngine, parent=None) -> None: def __init__(self, db: Database, engine: NostrEngine, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("FeedPage") self.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._db = db
self._engine = engine self._engine = engine
self._ranker = Ranker(db) self._ranker = Ranker(db)
self._page_nav_widgets: set[QObject] = set()
self._queue: list[dict[str, Any]] = [] self._queue: list[dict[str, Any]] = []
self._index = 0 self._index = 0
self._my_pubkey: str | None = None self._my_pubkey: str | None = None
@ -127,6 +107,8 @@ class FeedPage(QWidget):
self._engagement_label.setTextFormat(Qt.TextFormat.RichText) self._engagement_label.setTextFormat(Qt.TextFormat.RichText)
self._engagement_label.setWordWrap(True) self._engagement_label.setWordWrap(True)
eng_layout.addWidget(self._engagement_label) 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 = QFrame()
self._op_card.setObjectName("OpCard") self._op_card.setObjectName("OpCard")
@ -137,10 +119,12 @@ class FeedPage(QWidget):
self._op.setOpenExternalLinks(True) self._op.setOpenExternalLinks(True)
self._op.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self._op.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._op.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 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) op_card_lay.addWidget(self._op, stretch=1)
self._why = QLabel("") 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._why.setWordWrap(True)
self._thread_title = QLabel( self._thread_title = QLabel(
@ -156,6 +140,7 @@ class FeedPage(QWidget):
self._thread_scroll.setWidgetResizable(True) self._thread_scroll.setWidgetResizable(True)
self._thread_scroll.setWidget(self._thread_host) self._thread_scroll.setWidget(self._thread_host)
self._thread_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self._thread_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._thread_scroll.viewport().installEventFilter(self)
prev = QPushButton("◀ Previous") prev = QPushButton("◀ Previous")
next_ = QPushButton("Next ▶") next_ = QPushButton("Next ▶")
@ -173,6 +158,9 @@ class FeedPage(QWidget):
nav.addWidget(down) nav.addWidget(down)
nav.addStretch() nav.addStretch()
nav.addWidget(self._why) 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 = QVBoxLayout()
left.setSpacing(8) left.setSpacing(8)
@ -192,6 +180,9 @@ class FeedPage(QWidget):
split = QSplitter(Qt.Orientation.Horizontal) split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(lw) split.addWidget(lw)
split.addWidget(rw) 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(0, 3)
split.setStretchFactor(1, 2) split.setStretchFactor(1, 2)
split.setSizes([780, 420]) split.setSizes([780, 420])
@ -200,6 +191,34 @@ class FeedPage(QWidget):
outer.setContentsMargins(10, 8, 10, 8) outer.setContentsMargins(10, 8, 10, 8)
outer.addWidget(split) 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( def set_context(
self, self,
my_pubkey: str | None, my_pubkey: str | None,
@ -237,7 +256,7 @@ class FeedPage(QWidget):
if not ev: if not ev:
self._op.setPlainText(f"(not in local DB yet) {event_id}") self._op.setPlainText(f"(not in local DB yet) {event_id}")
return return
self._queue = [ev] self._queue = [cast(dict[str, Any], ev)]
self._index = 0 self._index = 0
self._show_current() self._show_current()
if not ev.get("deleted"): if not ev.get("deleted"):
@ -263,8 +282,10 @@ class FeedPage(QWidget):
if ev.get("deleted"): if ev.get("deleted"):
raw = html.escape(ev.get("content") or "") raw = html.escape(ev.get("content") or "")
self._op.setHtml( self._op.setHtml(
f"<p><i>Marked deleted locally</i></p><pre>{raw}</pre>" f"<body style=\"color:{TEXT};background:transparent\">"
f"<p style='color:gray'>{html.escape(ev['id'])}</p>" f"<p><i>Marked deleted locally</i></p>"
f"<pre style=\"color:{TEXT_MUTED}\">{raw}</pre>"
f"<p style=\"color:{TEXT_DIM}\">{html.escape(ev['id'])}</p></body>"
) )
self._clear_thread_rows() self._clear_thread_rows()
self._why.setText("") self._why.setText("")
@ -284,41 +305,42 @@ class FeedPage(QWidget):
pk = ev["pubkey"] pk = ev["pubkey"]
prof_row = self._db.get_latest_kind0_profile(pk) prof_row = self._db.get_latest_kind0_profile(pk)
parsed = parse_kind0_profile((prof_row or {}).get("content") or "") parsed = parse_kind0_profile(prof_row["content"] if prof_row else "")
disp = html.escape(display_name_from_profile(parsed))
npub = encode_npub(pk) npub = encode_npub(pk)
npub_e = html.escape(npub) pk_short = pk[:12] + ""
pk_short = html.escape(pk[:12] + "")
pic_url = _safe_http_url(parsed.get("picture"))
nip05 = html.escape((parsed.get("nip05") or "").strip()) if parsed.get("nip05") else "" 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 "" about = html.escape((parsed.get("about") or "")[:280]) if parsed.get("about") else ""
avatar_html = ( nip_line = (
f'<img src="{pic_url}" width="52" height="52" style="border-radius:10px;object-fit:cover;vertical-align:middle;margin-right:10px"/>' f"<div style='color:{TEXT_MUTED};font-size:15px;margin-top:4px'>{nip05}</div>" if nip05 else ""
if pic_url )
else '<span style="display:inline-block;width:52px;height:52px;border-radius:10px;background:#d9d0c3;margin-right:10px;vertical-align:middle"></span>' about_line = f"<div style='color:{TEXT_DIM};font-size:15px;margin-top:6px'>{about}</div>" 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"<div style='color:#6b5f4f;font-size:15px;margin-top:4px'>{nip05}</div>" if nip05 else ""
about_line = f"<div style='color:#5c5246;font-size:15px;margin-top:6px'>{about}</div>" if about else ""
tr = "" tr = ""
sr = ev.get("source_relay") or "" sr = ev.get("source_relay") or ""
if sr and "nostrarchives.com" in sr: if sr and "nostrarchives.com" in sr:
tr = "<div style='color:#7a6b55;font-size:15px;margin:6px 0'><i>Trending slice (nostrarchives)</i></div>" tr = f"<div style='color:{TEXT_DIM};font-size:15px;margin:6px 0'><i>Trending slice (nostrarchives)</i></div>"
eid = html.escape(ev["id"]) eid = html.escape(ev["id"])
md_body = markdown_html_fragment(ev.get("content") or "") md_body = markdown_html_fragment(ev.get("content") or "", db=self._db)
body = ( body = (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">" "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{_FEED_DOC_CSS}</head><body>" f"{FEED_DOC_CSS}</head><body>"
f"<div style='display:flex;align-items:flex-start;margin-bottom:12px'>" f"{author_block}"
f"{avatar_html}" f"<div style='color:{TEXT_DIM};font-size:15px;margin-bottom:8px'>Kind {int(ev['kind'])} · {int(ev['created_at'])}</div>"
f"<div style='flex:1'><div style='font-size:21px;font-weight:600'>{disp}</div>"
f"<div style='color:#6b5f4f;font-size:15px'>{npub_e} · {pk_short}</div>"
f"{nip_line}{about_line}</div></div>"
f"<div style='color:#7a6b55;font-size:15px;margin-bottom:8px'>Kind {int(ev['kind'])} · {int(ev['created_at'])}</div>"
f"{tr}" f"{tr}"
f"<div class=\"md\">{md_body}</div>" f"<div class=\"md\">{md_body}</div>"
f"<p style='color:#9a8b78;font-size:14px;margin-top:14px'>{eid}</p>" f"<p style='color:{TEXT_DIM};font-size:14px;margin-top:14px'>{eid}</p>"
"</body></html>" "</body></html>"
) )
self._op.setHtml(body) self._op.setHtml(body)
@ -329,41 +351,49 @@ class FeedPage(QWidget):
profiles = self._db.get_latest_kind0_profiles(pubkeys) profiles = self._db.get_latest_kind0_profiles(pubkeys)
for r in replies: for r in replies:
rpk = str(r["pubkey"]).lower() rpk = str(r["pubkey"]).lower()
pr = profiles.get(rpk) or {} pr = profiles.get(rpk)
rp = parse_kind0_profile(pr.get("content") or "") rp = parse_kind0_profile(pr["content"] if pr else "")
rname = html.escape(display_name_from_profile(rp)) plain = markdown_to_plain_text(r.get("content") or "", db=self._db)
rnpub = html.escape(encode_npub(rpk))
plain = markdown_to_plain_text(r.get("content") or "")
card = QFrame() card = QFrame()
card.setObjectName("ReplyCard") card.setObjectName("ReplyCard")
vl = QVBoxLayout(card) vl = QVBoxLayout(card)
vl.setContentsMargins(8, 6, 8, 8) vl.setContentsMargins(8, 6, 8, 8)
rk = int(r["kind"]) rk = int(r["kind"])
head = QLabel( head_b = NoteTextBrowser(self)
f"<span style='color:#8a7b6a;font-size:13px'>k{rk}</span> &nbsp; " head_b.setObjectName("ReplyHead")
f"<b>{rname}</b> &nbsp; <span style='color:#7a6b55;font-size:14px'>{rnpub}</span>" 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(
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body style=\"margin:0;padding:0\">"
f"{row_html}</body></html>"
) )
head.setTextFormat(Qt.TextFormat.RichText) head_b.installEventFilter(self)
head.setWordWrap(True) self._page_nav_widgets.add(head_b)
f_small = QFont()
f_small.setPointSize(13)
head.setFont(f_small)
body_te = QPlainTextEdit() body_te = QPlainTextEdit()
body_te.setObjectName("ReplyBody") body_te.setObjectName("ReplyBody")
body_te.setReadOnly(True) body_te.setReadOnly(True)
body_te.setPlainText(plain or "(empty)") body_te.setPlainText(plain or "(empty)")
body_te.setWordWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) body_te.setWordWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
body_te.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 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.setFrameShape(QFrame.Shape.NoFrame)
body_te.document().setDocumentMargin(0) body_te.document().setDocumentMargin(0)
body_te.setMinimumHeight(96) body_te.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
body_te.setMaximumHeight(440) body_te.installEventFilter(self)
vl.addWidget(head) vl.addWidget(head_b)
vl.addWidget(body_te) vl.addWidget(body_te)
self._thread_layout.addWidget(card) self._thread_layout.addWidget(card)
self._thread_layout.addStretch(1) self._thread_layout.addStretch(1)
QTimer.singleShot(0, self._refit_thread_reply_sizes)
def _prev(self) -> None: def _prev(self) -> None:
if self._queue: if self._queue:

4
src/imwald/ui/main_window.py

@ -236,7 +236,7 @@ class MainWindow(QMainWindow):
QMessageBox.information(self, "Edit", "Select an account.") QMessageBox.information(self, "Edit", "Select an account.")
return return
writes = resolve_for_account(self._db, acc.pubkey).write_urls 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: if dlg.exec() == QDialog.DialogCode.Accepted and dlg.last_published:
self._db.upsert_event(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.") QMessageBox.information(self, "Composer", "Select an account or add keys via onboarding.")
return return
writes = resolve_for_account(self._db, acc.pubkey).write_urls 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: if dlg.exec() == QDialog.DialogCode.Accepted and dlg.last_published:
self._db.upsert_event(dlg.last_published) self._db.upsert_event(dlg.last_published)

6
src/imwald/ui/markdown_editor_widget.py

@ -6,14 +6,16 @@ from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QFont from PySide6.QtGui import QFont
from PySide6.QtWidgets import QApplication, QPlainTextEdit, QSizePolicy, QSplitter, QTextBrowser, QVBoxLayout, QWidget 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 from imwald.core.md_render import markdown_html_document
class MarkdownBodyEditor(QWidget): class MarkdownBodyEditor(QWidget):
"""Plain-text Markdown editor with live rendered preview (local ``marked`` + nh3).""" """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) super().__init__(parent)
self._db = db
self._split = QSplitter(Qt.Orientation.Horizontal) self._split = QSplitter(Qt.Orientation.Horizontal)
self._source = QPlainTextEdit() self._source = QPlainTextEdit()
self._source.setPlaceholderText("Markdown source — preview updates as you type") self._source.setPlaceholderText("Markdown source — preview updates as you type")
@ -52,7 +54,7 @@ class MarkdownBodyEditor(QWidget):
self._update_preview() self._update_preview()
def _update_preview(self) -> None: 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: def setPlainText(self, text: str) -> None:
self._source.setPlainText(text) self._source.setPlainText(text)

2
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): class NoteTextBrowser(QTextBrowser):
"""Fetches HTTPS/HTTP images for rich notes (nh3 strips ``data:`` URIs on img).""" """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: if rtype == QTextDocument.ResourceType.ImageResource:
u = name.toString() u = name.toString()
if u.startswith(("https://", "http://")): if u.startswith(("https://", "http://")):

239
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"""
<style>
body {{ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 17px;
margin: 0; padding: 0; line-height: 1.5; color: {TEXT}; background: transparent; }}
a {{ color: {LINK}; }}
pre, code {{ font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 15px; }}
pre {{ background: {BG_CODE}; padding: 10px; border-radius: 8px; overflow-x: auto; color: {TEXT}; }}
blockquote {{ border-left: 3px solid {ACCENT_SOFT}; margin: 8px 0; padding: 4px 0 4px 12px; color: {TEXT_MUTED}; }}
.md img {{ max-width: min(100%, 400px); height: auto; border-radius: 8px; margin: 6px 0; }}
.md p {{ margin: 0.45em 0; }}
</style>
"""
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)

85
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
Loading…
Cancel
Save