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

2
src/imwald/app.py

@ -12,6 +12,7 @@ from imwald.config import db_path @@ -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: @@ -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())

116
src/imwald/core/author_html.py

@ -0,0 +1,116 @@ @@ -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 @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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"])}

94
src/imwald/core/md_render.py

@ -6,10 +6,18 @@ import html @@ -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 ``<img>``).
@ -22,6 +30,62 @@ _MARKED_PATH = Path(__file__).resolve().parents[1] / "ui" / "assets" / "vendor" @@ -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: @@ -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 ``<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)
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: @@ -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"<br\s*/?>", "\n", frag, flags=re.I)
frag = re.sub(r"</p\s*>", "\n\n", frag, flags=re.I)
frag = re.sub(r"</(div|blockquote|h[1-6]|li|tr)\s*>", "\n", frag, flags=re.I)
@ -120,19 +187,20 @@ def markdown_to_plain_text(md: str, *, max_source: int = 200_000) -> str: @@ -120,19 +187,20 @@ def markdown_to_plain_text(md: str, *, max_source: int = 200_000) -> str:
_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{background:#f4f4f4;padding:10px;border-radius:6px;overflow-x:auto;}
blockquote{border-left:3px solid #bbb;margin:8px 0;padding:4px 0 4px 12px;color:#444;}
pre{background:#0a100d;color:#dceee6;padding:10px;border-radius:6px;overflow-x:auto;}
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%;}
th,td{border:1px solid #ccc;padding:6px;}
th,td{border:1px solid #2a3d34;padding:6px;}
img{max-width:min(100%,400px);height:auto;}
</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."""
inner = markdown_html_fragment(md)
inner = markdown_html_fragment(md, db=db)
return (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{_PREVIEW_CSS}</head><body>{inner}</body></html>"

113
src/imwald/core/nip19.py

@ -1,10 +1,43 @@ @@ -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: @@ -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

8
src/imwald/core/nostr_crypto.py

@ -10,12 +10,14 @@ from coincurve import PrivateKey @@ -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( @@ -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)

141
src/imwald/core/nostr_entity_render.py

@ -0,0 +1,141 @@ @@ -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 @@ -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

4
src/imwald/core/relay_manager.py

@ -13,7 +13,7 @@ from enum import Enum @@ -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: @@ -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:

13
src/imwald/ui/composer_dialog.py

@ -20,6 +20,7 @@ from PySide6.QtWidgets import ( @@ -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): @@ -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): @@ -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): @@ -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( @@ -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()

10
src/imwald/ui/db_admin_page.py

@ -93,7 +93,10 @@ class DbAdminPage(QWidget): @@ -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): @@ -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:

208
src/imwald/ui/feed_page.py

@ -4,10 +4,10 @@ from __future__ import annotations @@ -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 ( @@ -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 = """
<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: #1e1b16; background: transparent; }
a { color: #2563eb; }
pre, code { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 15px; }
pre { background: #f0ebe3; padding: 10px; border-radius: 8px; overflow-x: auto; }
blockquote { border-left: 3px solid #c4b8a8; margin: 8px 0; padding: 4px 0 4px 12px; color: #4a4236; }
.md img { max-width: min(100%, 400px); height: auto; border-radius: 8px; margin: 6px 0; }
.md p { margin: 0.45em 0; }
</style>
"""
def _safe_http_url(u: str | None) -> str | None:
if not u or not isinstance(u, str):
return None
u = u.strip()
if u.startswith("https://") or u.startswith("http://"):
return html.escape(u, quote=True)
return None
def _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: @@ -82,37 +79,20 @@ def _format_engagement_html(stats: dict[str, Any]) -> str:
em_row = " &nbsp; ".join(emoji_bits) if emoji_bits else ""
head = " &nbsp;·&nbsp; ".join(parts) if parts else "no engagement in local DB yet"
if em_row:
return f"<div style='margin-bottom:6px'>{head}</div><div>{em_row}</div>"
return f"<div>{head}</div>"
inner = f"<div style='margin-bottom:6px'>{head}</div><div>{em_row}</div>"
else:
inner = f"<div>{head}</div>"
return f'<div style="color:{TEXT}">{inner}</div>'
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): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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): @@ -263,8 +282,10 @@ class FeedPage(QWidget):
if ev.get("deleted"):
raw = html.escape(ev.get("content") or "")
self._op.setHtml(
f"<p><i>Marked deleted locally</i></p><pre>{raw}</pre>"
f"<p style='color:gray'>{html.escape(ev['id'])}</p>"
f"<body style=\"color:{TEXT};background:transparent\">"
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._why.setText("")
@ -284,41 +305,42 @@ class FeedPage(QWidget): @@ -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'<img src="{pic_url}" width="52" height="52" style="border-radius:10px;object-fit:cover;vertical-align:middle;margin-right:10px"/>'
if pic_url
else '<span style="display:inline-block;width:52px;height:52px;border-radius:10px;background:#d9d0c3;margin-right:10px;vertical-align:middle"></span>'
nip_line = (
f"<div style='color:{TEXT_MUTED};font-size:15px;margin-top:4px'>{nip05}</div>" if nip05 else ""
)
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 = ""
sr = ev.get("source_relay") or ""
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"])
md_body = markdown_html_fragment(ev.get("content") or "")
md_body = markdown_html_fragment(ev.get("content") or "", db=self._db)
body = (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{_FEED_DOC_CSS}</head><body>"
f"<div style='display:flex;align-items:flex-start;margin-bottom:12px'>"
f"{avatar_html}"
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"{FEED_DOC_CSS}</head><body>"
f"{author_block}"
f"<div style='color:{TEXT_DIM};font-size:15px;margin-bottom:8px'>Kind {int(ev['kind'])} · {int(ev['created_at'])}</div>"
f"{tr}"
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>"
)
self._op.setHtml(body)
@ -329,41 +351,49 @@ class FeedPage(QWidget): @@ -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"<span style='color:#8a7b6a;font-size:13px'>k{rk}</span> &nbsp; "
f"<b>{rname}</b> &nbsp; <span style='color:#7a6b55;font-size:14px'>{rnpub}</span>"
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(
"<!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.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:

4
src/imwald/ui/main_window.py

@ -236,7 +236,7 @@ class MainWindow(QMainWindow): @@ -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): @@ -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)

6
src/imwald/ui/markdown_editor_widget.py

@ -6,14 +6,16 @@ from PySide6.QtCore import Qt, QTimer @@ -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): @@ -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)

2
src/imwald/ui/note_text_browser.py

@ -16,7 +16,7 @@ _USER_AGENT = "imwald/0.1 (PySide6; +https://github.com/nostr-protocol/nostr)" @@ -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://")):

239
src/imwald/ui/theme.py

@ -0,0 +1,239 @@ @@ -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 @@ @@ -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