From a03f9d1fc73a70f6db89fafcd7af8ba24ba8e90d Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 19 Apr 2026 14:49:51 +0200 Subject: [PATCH] beautify the profile pages --- src/imwald/core/author_html.py | 79 +++- src/imwald/core/forest_avatar.py | 23 +- src/imwald/core/kind0_profile.py | 55 +++ src/imwald/core/nip05.py | 90 +++++ src/imwald/core/nip38_status.py | 96 +++++ src/imwald/core/nostr_engine.py | 2 +- src/imwald/ui/feed_page.py | 177 ++++++--- src/imwald/ui/main_window.py | 47 ++- src/imwald/ui/media_viewer_dialog.py | 376 +++++++++++++++++++ src/imwald/ui/profile_page.py | 526 +++++++++++++++++++++------ src/imwald/ui/theme.py | 45 ++- tests/test_forest_avatar.py | 20 + tests/test_media_viewer.py | 16 + tests/test_nip05_collect.py | 21 ++ 14 files changed, 1388 insertions(+), 185 deletions(-) create mode 100644 src/imwald/core/nip05.py create mode 100644 src/imwald/core/nip38_status.py create mode 100644 src/imwald/ui/media_viewer_dialog.py create mode 100644 tests/test_forest_avatar.py create mode 100644 tests/test_media_viewer.py create mode 100644 tests/test_nip05_collect.py diff --git a/src/imwald/core/author_html.py b/src/imwald/core/author_html.py index f97bbb8..1903bac 100644 --- a/src/imwald/core/author_html.py +++ b/src/imwald/core/author_html.py @@ -5,6 +5,7 @@ from __future__ import annotations import html from imwald.core.kind0_profile import display_name_from_profile, display_name_from_profile_or_hex +from imwald.core.nip05 import favicon_url_for_domain, parse_nip05_identifier def safe_http_url(u: str | None) -> str | None: @@ -50,38 +51,78 @@ def avatar_img_or_placeholder( ) -def feed_op_author_block_html( +def format_nip05_chips_html( + identifiers: list[tuple[str, bool | None]], + *, + muted: str, + dim: str, + ok: str, + bad: str, +) -> str: + """ + Horizontal row of NIP-05 chips: favicon (per domain), identifier, verification mark. + + ``bool | None``: ``True`` verified, ``False`` failed, ``None`` pending / not checked. + """ + parts: list[str] = [] + for ident, okv in identifiers: + parsed = parse_nip05_identifier(ident) + dom = parsed[1] if parsed else "" + fav = favicon_url_for_domain(dom) if dom else "" + esc = html.escape(ident, quote=False) + esc_fav = html.escape(fav, quote=True) if fav else "" + icon = "" + if fav: + icon = ( + f'' + ) + if okv is True: + mark = f'' + elif okv is False: + mark = f'' + else: + mark = f'' + parts.append( + f'' + f"{icon}" + f'{esc}{mark}' + f"" + ) + if not parts: + return "" + return f'
{"".join(parts)}
' + + +def feed_op_author_compact_html( parsed: dict[str, str | None], npub_bech: str, - pk_short: str, - nip_line_html: str, - about_line_html: str, - *, pubkey_hex: str, - text: str, + *, + status_inner_html: str, + nip05_chips_html: str, muted: str, - dim: str, border: str, ) -> str: - """Top-of-note author row: picture, display name, npub, optional nip05/about lines (links to profile tab).""" - disp = html.escape(display_name_from_profile(parsed)) + """Feed OP header: avatar, npub only, optional NIP-38 status (HTML), NIP-05 chips.""" pk_l = pubkey_hex.strip().lower() href = html.escape(f"imwald://pub/{pk_l}", quote=True) av = avatar_img_or_placeholder(parsed, 52, border_hex=border, profile_href=href) npub_e = html.escape(npub_bech) - pk_s = html.escape(pk_short) - inner = ( - f'
' + status_block = "" + if status_inner_html.strip(): + status_block = f'
{status_inner_html}
' + return ( + f'
' f"{av}" - f'' - f'
{disp}
' - f'
{npub_e} · {pk_s}
' - f"{nip_line_html}{about_line_html}" + f'
' + f'' + f'
{npub_e}
' f"
" - f"
" + f"{status_block}" + f"{nip05_chips_html}" + f"
" ) - return inner def thread_reply_author_row_html( diff --git a/src/imwald/core/forest_avatar.py b/src/imwald/core/forest_avatar.py index 2e3178a..b9d5a84 100644 --- a/src/imwald/core/forest_avatar.py +++ b/src/imwald/core/forest_avatar.py @@ -6,11 +6,11 @@ import io import random -def build_forest_avatar_png(*, size: int = 192) -> bytes: +def build_forest_avatar_png(*, size: int = 192, seed: int = 42) -> bytes: """Return small PNG bytes: low resolution, palette, max zlib — keeps nostr.build uploads tiny.""" from PIL import Image, ImageDraw # type: ignore[import-not-found, import-untyped] - rng = random.Random(42) + rng = random.Random(int(seed) % (2**31)) w = h = max(64, min(size, 256)) img = Image.new("RGB", (w, h), (22, 68, 42)) dr = ImageDraw.Draw(img) @@ -46,3 +46,22 @@ def build_forest_avatar_png(*, size: int = 192) -> bytes: buf = io.BytesIO() paletted.save(buf, format="PNG", compress_level=9, optimize=True) return buf.getvalue() + + +def build_forest_banner_png(*, width: int = 1600, height: int = 260, seed: int = 0) -> bytes: + """ + Wide forest-themed PNG for profile headers when the user has no ``banner`` URL. + + Built from the same generative style as :func:`build_forest_avatar_png`, stretched to banner aspect. + """ + from PIL import Image # type: ignore[import-not-found, import-untyped] + + w = max(480, min(int(width), 2400)) + h = max(96, min(int(height), 480)) + sq = max(256, min(h * 2, 640)) + raw = build_forest_avatar_png(size=sq, seed=seed) + im = Image.open(io.BytesIO(raw)).convert("RGB") + im = im.resize((w, h), Image.Resampling.LANCZOS) + buf = io.BytesIO() + im.save(buf, format="PNG", compress_level=9, optimize=True) + return buf.getvalue() diff --git a/src/imwald/core/kind0_profile.py b/src/imwald/core/kind0_profile.py index ef1872d..9114827 100644 --- a/src/imwald/core/kind0_profile.py +++ b/src/imwald/core/kind0_profile.py @@ -5,6 +5,8 @@ from __future__ import annotations import json from typing import cast +from imwald.core.nip05 import scan_nip05_like_strings + def parse_kind0_profile(content: str) -> dict[str, str | None]: """Return display fields from kind-0 ``content`` JSON (best-effort).""" @@ -48,6 +50,59 @@ def display_name_from_profile(p: dict[str, str | None]) -> str: return (p.get("display_name") or p.get("name") or "").strip() or "anon" +def kind0_json_object_prefix(content: str) -> str: + """If ``content`` starts with JSON then has trailing text, return only the first ``{…}`` object.""" + s = (content or "").strip() + if not s or not s.lstrip().startswith("{"): + return s + depth = 0 + start = s.find("{") + if start < 0: + return s + for i in range(start, len(s)): + c = s[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + return s[start : i + 1] + return s + + +def collect_nip05_identifiers(kind0_content: str, kind0_tags: list[list[str]] | None) -> list[str]: + """ + All plausible NIP-05 identifiers for an author: JSON ``nip05``, ``nip05`` / ``nip-05`` tags, + and ``user@host``-shaped strings in the raw kind-0 ``content`` (deduped, order preserved). + """ + seen: set[str] = set() + out: list[str] = [] + + def add(s: str | None) -> None: + if not s or not isinstance(s, str): + return + t = s.strip() + if not t or "@" not in t: + return + k = t.lower() + if k in seen: + return + seen.add(k) + out.append(t) + + p = parse_kind0_profile(kind0_json_object_prefix(kind0_content or "")) + add(p.get("nip05")) + for t in kind0_tags or []: + if len(t) < 2: + continue + name = str(t[0]).lower() + if name in ("nip05", "nip-05"): + add(str(t[1])) + for s in scan_nip05_like_strings(kind0_content or ""): + add(s) + return out + + def display_name_from_profile_or_hex(p: dict[str, str | None], pubkey_hex: str) -> str: """Like ``display_name_from_profile`` but falls back to a short hex id when no name is set.""" n = (p.get("display_name") or p.get("name") or "").strip() diff --git a/src/imwald/core/nip05.py b/src/imwald/core/nip05.py new file mode 100644 index 0000000..fd4b7ef --- /dev/null +++ b/src/imwald/core/nip05.py @@ -0,0 +1,90 @@ +"""NIP-05 identifier parsing and HTTPS ``/.well-known/nostr.json`` verification (Jumble-style).""" + +from __future__ import annotations + +import json +import re +import urllib.error +import urllib.parse +import urllib.request +from typing import Final + +_USER_AGENT: Final = "imwald/0.1 (NIP-05; +https://github.com/nostr-protocol/nostr)" + +# Loose identifier: local@domain with plausible host (NIP-05 shape). +_NIP05_LIKE: Final = re.compile( + r"\b([a-z0-9._+-]+)@([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)\b", + re.IGNORECASE, +) + + +def parse_nip05_identifier(raw: str) -> tuple[str, str] | None: + """ + Split ``local@domain`` into parts suitable for NIP-05 well-known lookup. + + Returns ``(local, domain_lower)`` or ``None`` if the string is not a plausible NIP-05 id. + """ + s = (raw or "").strip() + if not s or "@" not in s: + return None + local, _, host = s.rpartition("@") + local = local.strip() + host = host.strip().lower() + if not local or not host or "." not in host: + return None + if any(c in local for c in (" ", "\t", "\n", "/", "\\")): + return None + return local, host + + +def favicon_url_for_domain(domain: str) -> str: + """Small favicon URL for UI chips (same pattern many Nostr clients use).""" + d = domain.strip().lower() + return f"https://www.google.com/s2/favicons?sz=16&domain={urllib.parse.quote(d, safe='')}" + + +def verify_nip05(pubkey_hex: str, nip05_identifier: str, *, timeout: float = 10.0) -> bool: + """ + Return whether ``nip05_identifier`` maps to ``pubkey_hex`` via + ``https:///.well-known/nostr.json?name=`` (NIP-05). + """ + parsed = parse_nip05_identifier(nip05_identifier) + if not parsed: + return False + local, domain = parsed + pk = pubkey_hex.strip().lower() + if len(pk) != 64 or any(c not in "0123456789abcdef" for c in pk): + return False + url = f"https://{domain}/.well-known/nostr.json?name={urllib.parse.quote(local, safe='')}" + try: + req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}, method="GET") + with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 + raw = resp.read(256_000) + data = json.loads(raw.decode("utf-8", errors="replace")) + except (OSError, UnicodeError, json.JSONDecodeError, urllib.error.URLError): + return False + if not isinstance(data, dict): + return False + names = data.get("names") + if not isinstance(names, dict): + return False + for key, val in names.items(): + if str(key).lower() == local.lower() and isinstance(val, str): + got = val.strip().lower() + if len(got) == 64 and all(c in "0123456789abcdef" for c in got): + return got == pk + return False + + +def scan_nip05_like_strings(text: str) -> list[str]: + """Return ordered unique substrings in ``text`` that look like ``user@host.tld``.""" + seen: set[str] = set() + out: list[str] = [] + for m in _NIP05_LIKE.finditer(text or ""): + s = m.group(0).strip() + key = s.lower() + if key in seen: + continue + seen.add(key) + out.append(s) + return out diff --git a/src/imwald/core/nip38_status.py b/src/imwald/core/nip38_status.py new file mode 100644 index 0000000..13d933f --- /dev/null +++ b/src/imwald/core/nip38_status.py @@ -0,0 +1,96 @@ +"""NIP-38 (kind 30315) user status: pick latest non-expired status event from the local DB.""" + +from __future__ import annotations + +import json +import time +from typing import Any, cast + +from imwald.core.database import Database + + +def _expiration_ts(tags: list[list[str]]) -> int | None: + for t in tags: + if len(t) >= 2 and str(t[0]).lower() == "expiration": + try: + return int(str(t[1]).strip()) + except ValueError: + return None + return None + + +def _first_link_tag(tags: list[list[str]]) -> str | None: + for t in tags: + if len(t) >= 2 and str(t[0]).lower() == "r": + u = str(t[1]).strip() + if u.startswith("https://") or u.startswith("http://"): + return u + return None + + +def get_active_user_status_event(db: Database, pubkey_hex: str) -> dict[str, Any] | None: + """ + Latest **non-expired** kind ``30315`` for ``pubkey_hex`` with non-empty ``content``. + + Prefers ``d`` tag ``general``, then ``music``, then any other ``d`` with a body (newest first). + """ + pk = pubkey_hex.strip().lower() + if len(pk) != 64 or any(c not in "0123456789abcdef" for c in pk): + return None + now = int(time.time()) + for d in ("general", "music"): + ev = db.get_event_by_addressable(30315, pk, d) + if not ev or ev.get("deleted"): + continue + tags_raw = ev.get("tags") + tags = cast(list[list[str]], tags_raw) if isinstance(tags_raw, list) else [] + exp = _expiration_ts(tags) + if exp is not None and exp <= now: + continue + if not str(ev.get("content") or "").strip(): + continue + return ev + # Other custom ``d`` values: newest 30315 by this author with content + not expired. + cur = db.conn().execute( + """ + SELECT id, pubkey, created_at, kind, content, sig, tags_json, deleted, source_relay + FROM events + WHERE deleted = 0 AND kind = 30315 AND lower(pubkey) = lower(?) + ORDER BY created_at DESC + LIMIT 24 + """, + (pk,), + ) + for row in cur.fetchall(): + tags = cast(list[list[str]], json.loads(row["tags_json"] or "[]")) + d_val = "" + for t in tags: + if len(t) >= 2 and str(t[0]).lower() == "d": + d_val = str(t[1] or "") + break + if d_val in ("general", "music"): + continue + exp = _expiration_ts(tags) + if exp is not None and exp <= now: + continue + if not str(row["content"] or "").strip(): + continue + return { + "id": row["id"], + "pubkey": row["pubkey"], + "created_at": int(row["created_at"]), + "kind": int(row["kind"]), + "content": row["content"], + "sig": row["sig"], + "tags": tags, + "deleted": bool(row["deleted"]), + "source_relay": row["source_relay"], + } + return None + + +def status_link_from_event(ev: dict[str, Any]) -> str | None: + """First ``r`` tag with ``http(s)`` URL on a status event, if any.""" + tags_raw = ev.get("tags") + tags = cast(list[list[str]], tags_raw) if isinstance(tags_raw, list) else [] + return _first_link_tag(tags) diff --git a/src/imwald/core/nostr_engine.py b/src/imwald/core/nostr_engine.py index 0a3f69b..7126a6f 100644 --- a/src/imwald/core/nostr_engine.py +++ b/src/imwald/core/nostr_engine.py @@ -33,7 +33,7 @@ from imwald.core.relay_policy import ( log = logging.getLogger(__name__) # Per-author backfill: profile + lists + NIP-30 inventory (Jumble-style). -AUTHOR_METADATA_KINDS: tuple[int, ...] = (0, 10015, 30000, 10030, 30030) +AUTHOR_METADATA_KINDS: tuple[int, ...] = (0, 10015, 30000, 10030, 30030, 30315) _AUTHOR_META_SUB_ID = "imwald-ameta" diff --git a/src/imwald/ui/feed_page.py b/src/imwald/ui/feed_page.py index b23dfe6..0e11025 100644 --- a/src/imwald/ui/feed_page.py +++ b/src/imwald/ui/feed_page.py @@ -8,7 +8,7 @@ import re from collections.abc import Sequence from typing import Any, cast -from PySide6.QtCore import QEvent, QObject, Qt, QTimer, Signal, QUrl +from PySide6.QtCore import QEvent, QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, QUrl from PySide6.QtGui import QDesktopServices, QKeyEvent, QTextOption from PySide6.QtWidgets import ( @@ -24,16 +24,26 @@ from PySide6.QtWidgets import ( QWidget, ) -from imwald.core.author_html import feed_op_author_block_html, thread_reply_author_row_html +from imwald.core.author_html import ( + feed_op_author_compact_html, + format_nip05_chips_html, + thread_reply_author_row_html, +) from imwald.core.database import Database from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX -from imwald.core.kind0_profile import parse_kind0_profile +from imwald.core.kind0_profile import ( + collect_nip05_identifiers, + kind0_json_object_prefix, + parse_kind0_profile, +) +from imwald.core.nip38_status import get_active_user_status_event 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.media_viewer_dialog import try_open_media_url_in_app from imwald.ui.note_text_browser import NoteTextBrowser -from imwald.ui.theme import BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED +from imwald.ui.theme import ACCENT, BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED FEED_KINDS = (1, 20, 21, 30023, 9802, 11) @@ -46,6 +56,25 @@ def _nip30_tags(ev_row: dict[str, Any]) -> list[list[str]] | None: return cast(list[list[str]], t) if isinstance(t, list) else None +class _Nip05VerifySignals(QObject): + finished = Signal(int, object) + + +class _Nip05VerifyRunnable(QRunnable): + def __init__(self, pk: str, cands: list[str], gen: int, out: _Nip05VerifySignals) -> None: + super().__init__() + self._pk = pk + self._cands = cands + self._gen = gen + self._out = out + + def run(self) -> None: + from imwald.core.nip05 import verify_nip05 + + pairs: list[tuple[str, bool]] = [(c, verify_nip05(self._pk, c, timeout=9.0)) for c in self._cands] + self._out.finished.emit(self._gen, pairs) + + def _set_plain_height_to_content(te: QPlainTextEdit) -> None: doc = te.document() lay = doc.documentLayout() @@ -158,6 +187,12 @@ class FeedPage(QWidget): self._rendered_op_id: str | None = None self._rendered_reply_sig: tuple[str, ...] | None = None self._thread_card_by_eid: dict[str, QFrame] = {} + self._op_ev_snapshot: dict[str, Any] | None = None + self._op_nip05_gen = 0 + self._nip05_sigs = _Nip05VerifySignals(self) + self._nip05_sigs.finished.connect(self._on_nip05_verify_done) + self._nip05_pool = QThreadPool(self) + self._nip05_pool.setMaxThreadCount(2) self._engagement = QFrame() self._engagement.setObjectName("EngagementBar") @@ -264,6 +299,8 @@ class FeedPage(QWidget): return s = url.toString() if s.startswith("https://") or s.startswith("http://"): + if try_open_media_url_in_app(self.window(), url): + return QDesktopServices.openUrl(url) def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802 @@ -361,6 +398,7 @@ class FeedPage(QWidget): def show_event(self, event_id: str) -> None: ev = self._db.get_event(event_id) if not ev: + self._op_ev_snapshot = None self._op.setPlainText(f"(not in local DB yet) {event_id}") return self._queue = [cast(dict[str, Any], ev)] @@ -369,6 +407,77 @@ class FeedPage(QWidget): if not ev.get("deleted"): self._db.mark_feed_viewed(self._feed_viewer_key(), ev["id"]) + def _build_op_html(self, ev: dict[str, Any], nip05_verified: list[tuple[str, bool]] | None) -> str: + """Full OP ``QTextBrowser`` document: compact author row + note body.""" + pk = str(ev["pubkey"]) + prof_row = self._db.get_latest_kind0_profile(pk) + k0_ev = self._db.get_latest_kind0_event(pk) + content = (k0_ev.get("content") if k0_ev else None) or (prof_row["content"] if prof_row else "") or "{}" + tags_l: list[list[str]] = k0_ev["tags"] if k0_ev else [] + raw_prof = prof_row["content"] if prof_row else content + parsed = parse_kind0_profile(kind0_json_object_prefix(raw_prof)) + npub = encode_npub(pk) + candidates = collect_nip05_identifiers(content, tags_l) + if nip05_verified is None: + states: list[tuple[str, bool | None]] = [(c, None) for c in candidates] + else: + vmap = {a.strip().lower(): b for a, b in nip05_verified} + states = [(c, vmap.get(c.strip().lower(), False)) for c in candidates] + chips = format_nip05_chips_html( + states, muted=TEXT_MUTED, dim=TEXT_DIM, ok=ACCENT, bad="#b86a6a" + ) + st_ev = get_active_user_status_event(self._db, pk) + st_html = "" + if st_ev: + st_html = markdown_html_fragment( + st_ev.get("content") or "", + db=self._db, + nip30_tags=_nip30_tags(st_ev), + nip30_author_pubkey=pk, + ) + author_block = feed_op_author_compact_html( + parsed, + npub, + pk, + status_inner_html=st_html, + nip05_chips_html=chips, + muted=TEXT_MUTED, + border=BORDER, + ) + tr = "" + sr = ev.get("source_relay") or "" + if sr and "nostrarchives.com" in sr: + tr = f"
Trending slice (nostrarchives)
" + eid = html.escape(str(ev["id"])) + md_body = markdown_html_fragment( + ev.get("content") or "", + db=self._db, + nip30_tags=_nip30_tags(ev), + nip30_author_pubkey=pk, + ) + return ( + "" + f"{FEED_DOC_CSS}" + f"{author_block}" + f"
Kind {int(ev['kind'])} · {int(ev['created_at'])}
" + f"{tr}" + f"
{md_body}
" + f"

{eid}

" + "" + ) + + def _on_nip05_verify_done(self, gen: int, pairs_obj: object) -> None: + if gen != self._op_nip05_gen: + return + ev = self._op_ev_snapshot + op_id = self._rendered_op_id + if not ev or not op_id or str(ev.get("id")) != op_id: + return + if not isinstance(pairs_obj, list): + return + pairs = cast(list[tuple[str, bool]], pairs_obj) + self._op.setHtml(self._build_op_html(ev, pairs)) + def _clear_thread_rows(self) -> None: self._thread_card_by_eid.clear() while self._thread_layout.count(): @@ -385,6 +494,7 @@ class FeedPage(QWidget): if not self._queue: self._rendered_op_id = None self._rendered_reply_sig = None + self._op_ev_snapshot = None self._op.setPlainText("No events in local database yet — wait for relay sync.") self._clear_thread_rows() self._why.setText("") @@ -394,6 +504,7 @@ class FeedPage(QWidget): if ev.get("deleted"): self._rendered_op_id = None self._rendered_reply_sig = None + self._op_ev_snapshot = None raw = html.escape(ev.get("content") or "") self._op.setHtml( f"" @@ -431,54 +542,20 @@ class FeedPage(QWidget): self._rendered_op_id = root_id self._rendered_reply_sig = reply_sig - pk = op_pk - prof_row = self._db.get_latest_kind0_profile(pk) - parsed = parse_kind0_profile(prof_row["content"] if prof_row else "") - npub = encode_npub(pk) - 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 "" - nip_line = ( - f"
{nip05}
" if nip05 else "" - ) - about_line = f"
{about}
" if about else "" - author_block = feed_op_author_block_html( - parsed, - npub, - pk_short, - nip_line, - about_line, - pubkey_hex=pk, - text=TEXT, - muted=TEXT_MUTED, - dim=TEXT_DIM, - border=BORDER, - ) - - tr = "" - sr = ev.get("source_relay") or "" - if sr and "nostrarchives.com" in sr: - tr = f"
Trending slice (nostrarchives)
" - - eid = html.escape(ev["id"]) - md_body = markdown_html_fragment( - ev.get("content") or "", - db=self._db, - nip30_tags=_nip30_tags(ev), - nip30_author_pubkey=pk, - ) - body = ( - "" - f"{FEED_DOC_CSS}" - f"{author_block}" - f"
Kind {int(ev['kind'])} · {int(ev['created_at'])}
" - f"{tr}" - f"
{md_body}
" - f"

{eid}

" - "" - ) + self._op_ev_snapshot = ev + body = self._build_op_html(ev, None) self._op.setHtml(body) + pk = op_pk + prof_row2 = self._db.get_latest_kind0_profile(pk) + k0_ev2 = self._db.get_latest_kind0_event(pk) + content2 = (k0_ev2.get("content") if k0_ev2 else None) or (prof_row2["content"] if prof_row2 else "") or "{}" + tags_l2: list[list[str]] = k0_ev2["tags"] if k0_ev2 else [] + candidates = collect_nip05_identifiers(content2, tags_l2) + if candidates: + self._op_nip05_gen += 1 + self._nip05_pool.start(_Nip05VerifyRunnable(pk, candidates, self._op_nip05_gen, self._nip05_sigs)) + self._thread_scroll.setUpdatesEnabled(False) try: self._clear_thread_rows() diff --git a/src/imwald/ui/main_window.py b/src/imwald/ui/main_window.py index 614034b..7329bc2 100644 --- a/src/imwald/ui/main_window.py +++ b/src/imwald/ui/main_window.py @@ -301,8 +301,8 @@ class MainWindow(QMainWindow): def _flush_ingest_ui_refresh(self) -> None: if self._stack.currentIndex() == 0: cur = self._browser_tabs.currentWidget() - if cur is self._feed: - self._feed.refresh_tail() + if isinstance(cur, FeedPage): + cur.refresh_tail() elif isinstance(cur, ProfilePage): cur.refresh() self._notif.refresh_all() @@ -432,7 +432,7 @@ class MainWindow(QMainWindow): existing.refresh() return page = ProfilePage(self._db, self._engine, pk, self._browser_tabs) - page.open_note.connect(self._open_event) + page.open_note_new_tab.connect(self._open_event_in_new_tab) page.open_profile.connect(self._open_profile_tab) self._profile_tabs_by_pubkey[pk] = page self._browser_tabs.addTab(page, page.tab_title()) @@ -448,6 +448,7 @@ class MainWindow(QMainWindow): if v is w: del self._profile_tabs_by_pubkey[k] break + if w is not None: w.deleteLater() def _wire_pages(self) -> None: @@ -465,6 +466,46 @@ class MainWindow(QMainWindow): self._go_stack_page(0) self._feed.show_event(event_id) + def _open_event_in_new_tab(self, event_id: str) -> None: + eid = event_id.strip().lower() + if len(eid) != 64 or any(c not in "0123456789abcdef" for c in eid): + return + self._go_stack_page(0) + fp = FeedPage(self._db, self._engine) + pk = self._current_pubkey() + following: set[str] = set() + list300: set[str] = set() + if pk: + following = self._db.list_following_pubkeys(pk) + list300 = self._db.list_kind30000_list_pubkeys(pk) + fp.set_context(pk, following, list300) + fp.profile_requested.connect(self._open_profile_tab) + fp.show_event(eid) + self._browser_tabs.addTab(fp, self._event_view_tab_title(eid)) + self._browser_tabs.setCurrentWidget(fp) + + def _event_view_tab_title(self, event_id: str) -> str: + ev = self._db.get_event(event_id) + if not ev: + return f"Note {event_id[:10]}…" + nip30_tags = ev["tags"] + pk_ev = str(ev.get("pubkey") or "").strip().lower() or None + summ = markdown_plain_summary( + ev.get("content") or "", + max_len=40, + db=self._db, + nip30_tags=nip30_tags, + nip30_author_pubkey=pk_ev, + ) + if summ.strip(): + t = summ.strip().replace("\n", " ") + return (t[:30] + "…") if len(t) > 30 else t + try: + k = int(ev.get("kind", 0)) + except (TypeError, ValueError): + k = 0 + return f"k{k} · {event_id[:10]}…" + def _nip09_from_db(self, event_id: str, pubkey: str) -> None: acc = next((a for a in self._accounts if a.pubkey.lower() == pubkey.lower()), None) if not acc: diff --git a/src/imwald/ui/media_viewer_dialog.py b/src/imwald/ui/media_viewer_dialog.py new file mode 100644 index 0000000..015dc8e --- /dev/null +++ b/src/imwald/ui/media_viewer_dialog.py @@ -0,0 +1,376 @@ +"""In-app viewer for remote images, audio, and video (HTTP/S) opened from note links.""" + +from __future__ import annotations + +import urllib.request +from typing import Final, Literal + +from PySide6.QtCore import QByteArray, QObject, QRunnable, Qt, QThreadPool, QUrl, Signal +from PySide6.QtGui import ( + QClipboard, + QCloseEvent, + QGuiApplication, + QImage, + QKeySequence, + QPixmap, + QResizeEvent, + QShortcut, + QWheelEvent, +) +from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer +from PySide6.QtMultimediaWidgets import QVideoWidget +from PySide6.QtSvgWidgets import QSvgWidget +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QScrollArea, + QSizePolicy, + QSlider, + QStyle, + QVBoxLayout, + QWidget, +) + +from imwald.ui.theme import ACCENT, BG_CARD, BG_FIELD, BG_WINDOW, BORDER, TEXT, TEXT_DIM, TEXT_MUTED + +_MAX_BYTES: Final = 8 * 1024 * 1024 +_USER_AGENT: Final = "imwald/0.1 (PySide6; +https://github.com/nostr-protocol/nostr)" + +_IMAGE_EXT: Final = frozenset( + {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp", ".tif", ".tiff", ".svg"} +) +_AUDIO_EXT: Final = frozenset({".mp3", ".ogg", ".opus", ".wav", ".flac", ".m4a", ".aac", ".oga"}) +_VIDEO_EXT: Final = frozenset({".mp4", ".webm", ".mov", ".mkv", ".ogv", ".m4v", ".avi", ".wmv"}) + + +def classify_media_url(url: QUrl) -> Literal["image", "audio", "video"] | None: + """Best-effort media kind from URL path (query stripped). Unknown → ``None`` (open externally).""" + if url.scheme() not in ("http", "https"): + return None + path = url.path().lower() + if not path: + return None + dot = path.rfind(".") + ext = path[dot:] if dot >= 0 else "" + if ext in _IMAGE_EXT: + return "image" + if ext in _AUDIO_EXT: + return "audio" + if ext in _VIDEO_EXT: + return "video" + return None + + +class _FetchSignals(QObject): + ok = Signal(bytes) + fail = Signal(str) + + +class _FetchRunnable(QRunnable): + def __init__(self, url: str, sigs: _FetchSignals) -> None: + super().__init__() + self._url = url + self._sigs = sigs + + def run(self) -> None: + try: + req = urllib.request.Request(self._url, headers={"User-Agent": _USER_AGENT}, method="GET") + with urllib.request.urlopen(req, timeout=45) as resp: # noqa: S310 + data = resp.read(_MAX_BYTES + 1) + if len(data) > _MAX_BYTES: + self._sigs.fail.emit("File is larger than the in-app viewer limit (8 MiB).") + return + self._sigs.ok.emit(data) + except OSError as e: + self._sigs.fail.emit(str(e) or "Download failed.") + + +class _ImageCanvas(QWidget): + """Scrollable image with Ctrl+wheel zoom (keeps a crisp source pixmap).""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._src = QPixmap() + self._zoom = 1.0 + self._label = QLabel() + self._label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) + self._scroll = QScrollArea(self) + self._scroll.setWidget(self._label) + self._scroll.setWidgetResizable(True) + self._scroll.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._scroll.setFrameShape(QScrollArea.Shape.NoFrame) + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(self._scroll) + + def set_pixmap(self, pm: QPixmap) -> None: + self._src = pm + self._zoom = 1.0 + self._apply() + + def wheelEvent(self, event: QWheelEvent) -> None: + if event.modifiers() & Qt.KeyboardModifier.ControlModifier: + delta = 1.0 + (0.12 if event.angleDelta().y() > 0 else -0.12) + self._zoom = max(0.25, min(6.0, self._zoom * delta)) + self._apply() + event.accept() + return + super().wheelEvent(event) + + def resizeEvent(self, event: QResizeEvent) -> None: + super().resizeEvent(event) + self._apply() + + def _apply(self) -> None: + if self._src.isNull(): + self._label.clear() + return + vw = max(self._scroll.viewport().width() - 8, 120) + vh = max(self._scroll.viewport().height() - 8, 120) + tw = max(int(vw * self._zoom), 1) + th = max(int(vh * self._zoom), 1) + fitted = self._src.scaled( + tw, + th, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self._label.setPixmap(fitted) + + +class MediaViewerDialog(QDialog): + """ + Non-modal dark viewer: raster/SVG images (async fetch + Ctrl-zoom), or Qt Multimedia for A/V. + + Use :func:`try_open_media_url_in_app` from link handlers so normal web links still open externally. + """ + + def __init__( + self, + parent: QWidget | None, + url: QUrl, + kind: Literal["image", "audio", "video"], + ) -> None: + super().__init__(parent) + self.setWindowTitle(self._short_title(url)) + self.setModal(False) + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + self.resize(920, 640) + self.setStyleSheet( + f"QDialog {{ background-color: {BG_WINDOW}; color: {TEXT}; }}" + f"QLabel {{ color: {TEXT}; }}" + f"QPushButton {{ background-color: {BG_CARD}; color: {TEXT}; border: 1px solid {BORDER}; " + f"padding: 6px 14px; border-radius: 8px; }}" + f"QPushButton:hover {{ border-color: {ACCENT}; color: {ACCENT}; }}" + f"QSlider::groove:horizontal {{ height: 6px; background: {BG_FIELD}; border-radius: 3px; }}" + f"QSlider::handle:horizontal {{ width: 14px; margin: -5px 0; background: {ACCENT}; border-radius: 7px; }}" + ) + self._url = url + self._kind = kind + self._pool = QThreadPool(self) + self._pool.setMaxThreadCount(1) + self._player: QMediaPlayer | None = None + self._audio_out: QAudioOutput | None = None + + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(10) + + self._subtitle = QLabel(url.toString()) + self._subtitle.setWordWrap(True) + self._subtitle.setStyleSheet(f"color:{TEXT_MUTED};font-size:13px") + root.addWidget(self._subtitle) + self._image_wait: QLabel | None = None + + self._stack_host = QWidget() + self._stack_layout = QVBoxLayout(self._stack_host) + self._stack_layout.setContentsMargins(0, 0, 0, 0) + root.addWidget(self._stack_host, stretch=1) + + btn_row = QHBoxLayout() + copy_b = QPushButton("Copy link") + copy_b.clicked.connect(self._copy_url) + ext_b = QPushButton("Open in browser") + ext_b.clicked.connect(self._open_external) + btn_row.addWidget(copy_b) + btn_row.addWidget(ext_b) + btn_row.addStretch() + close_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + close_box.rejected.connect(self.close) + close_box.accepted.connect(self.close) + btn_row.addWidget(close_box) + root.addLayout(btn_row) + + esc = QShortcut(QKeySequence(Qt.Key.Key_Escape), self) + esc.activated.connect(self.close) + + if kind == "image": + self._setup_image(url) + else: + self._setup_av(url, kind) + + @staticmethod + def _short_title(url: QUrl) -> str: + s = url.toString() + return s if len(s) <= 56 else s[:26] + "…" + s[-24:] + + def _copy_url(self) -> None: + QGuiApplication.clipboard().setText(self._url.toString(), QClipboard.Mode.Clipboard) + + def _open_external(self) -> None: + from PySide6.QtGui import QDesktopServices + + QDesktopServices.openUrl(self._url) + + def _setup_image(self, url: QUrl) -> None: + self._image_wait = QLabel("Loading…") + self._image_wait.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._image_wait.setStyleSheet(f"color:{TEXT_DIM};font-size:18px;padding:40px") + self._stack_layout.addWidget(self._image_wait) + sigs = _FetchSignals(self) + sigs.ok.connect(self._on_image_bytes) + sigs.fail.connect(self._on_image_fail) + self._pool.start(_FetchRunnable(url.toString(), sigs)) + + def _on_image_bytes(self, data: bytes) -> None: + w = self._image_wait + if w is not None: + self._stack_layout.removeWidget(w) + w.deleteLater() + self._image_wait = None + u = self._url.toString().lower() + if u.endswith(".svg") or data.lstrip().startswith((b" None: + w = self._image_wait + if w is None: + return + w.setText(f"Could not load image.\n{msg}") + w.setStyleSheet(f"color:{TEXT_DIM};font-size:14px;padding:24px") + + def _setup_av(self, url: QUrl, kind: Literal["audio", "video"]) -> None: + self._audio_out = QAudioOutput(self) + self._player = QMediaPlayer(self) + self._player.setAudioOutput(self._audio_out) + vw: QVideoWidget | None = None + if kind == "video": + vw = QVideoWidget() + vw.setMinimumSize(480, 270) + self._player.setVideoOutput(vw) + self._stack_layout.addWidget(vw, stretch=1) + else: + art = QLabel("♪") + art.setAlignment(Qt.AlignmentFlag.AlignCenter) + art.setStyleSheet(f"font-size:96px;color:{TEXT_DIM};padding:48px") + self._stack_layout.addWidget(art, stretch=1) + + ctrl = QHBoxLayout() + self._play_btn = QPushButton() + self._play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + self._play_btn.clicked.connect(self._toggle_play) + ctrl.addWidget(self._play_btn) + self._pos_slider = QSlider(Qt.Orientation.Horizontal) + self._pos_slider.setRange(0, 0) + self._pos_slider.sliderMoved.connect(self._seek) + ctrl.addWidget(self._pos_slider, stretch=1) + self._time_lbl = QLabel("0:00 / 0:00") + self._time_lbl.setStyleSheet(f"color:{TEXT_MUTED}") + ctrl.addWidget(self._time_lbl) + wrap = QWidget() + wrap.setLayout(ctrl) + self._stack_layout.addWidget(wrap) + + self._player.setSource(url) + self._player.playbackStateChanged.connect(self._sync_play_icon) + self._player.positionChanged.connect(self._on_position) + self._player.durationChanged.connect(self._on_duration) + self._player.errorOccurred.connect(self._on_player_error) # type: ignore[arg-type] + self._player.play() + + def _toggle_play(self) -> None: + if self._player is None: + return + if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self._player.pause() + else: + self._player.play() + + def _sync_play_icon(self) -> None: + if self._player is None: + return + if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self._play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPause)) + else: + self._play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + + def _seek(self, pos: int) -> None: + if self._player is not None: + self._player.setPosition(pos) + + def _on_position(self, pos: int) -> None: + if self._player is None: + return + d = self._player.duration() + if d > 0 and self._pos_slider.maximum() != d: + self._pos_slider.setMaximum(d) + self._pos_slider.blockSignals(True) + self._pos_slider.setValue(pos) + self._pos_slider.blockSignals(False) + self._time_lbl.setText(f"{self._fmt_ms(pos)} / {self._fmt_ms(max(d, 0))}") + + def _on_duration(self, d: int) -> None: + self._pos_slider.setMaximum(max(d, 0)) + + @staticmethod + def _fmt_ms(ms: int) -> str: + s = max(ms, 0) // 1000 + m, s = divmod(s, 60) + if m >= 60: + h, m = divmod(m, 60) + return f"{h}:{m:02d}:{s:02d}" + return f"{m}:{s:02d}" + + def _on_player_error(self, *_args: object) -> None: + if self._player is None: + return + err = self._player.errorString() + QMessageBox.warning(self, "Playback", err or "Media playback failed.") + + def closeEvent(self, event: QCloseEvent) -> None: + if self._player is not None: + self._player.stop() + super().closeEvent(event) + + +def try_open_media_url_in_app(parent: QWidget | None, url: QUrl) -> bool: + """ + If ``url`` looks like a direct media file we support, open :class:`MediaViewerDialog` and return ``True``. + + Otherwise return ``False`` so callers can fall back to :class:`QDesktopServices`. + """ + kind = classify_media_url(url) + if kind is None: + return False + dlg = MediaViewerDialog(parent, url, kind) + dlg.show() + dlg.raise_() + dlg.activateWindow() + return True diff --git a/src/imwald/ui/profile_page.py b/src/imwald/ui/profile_page.py index e135f99..8c93f52 100644 --- a/src/imwald/ui/profile_page.py +++ b/src/imwald/ui/profile_page.py @@ -4,27 +4,279 @@ from __future__ import annotations import html import json +from datetime import datetime, timezone from typing import cast -from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, Signal, QUrl -from PySide6.QtGui import QDesktopServices -from PySide6.QtWidgets import QFrame, QScrollArea, QTabWidget, QVBoxLayout, QWidget +from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal, QUrl +from PySide6.QtGui import QDesktopServices, QMouseEvent, QPainter, QPainterPath, QPixmap, QResizeEvent +from PySide6.QtWidgets import QFrame, QLabel, QSizePolicy, QSplitter, QTabWidget, QVBoxLayout, QWidget -from imwald.core.author_html import avatar_img_or_placeholder -from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX from imwald.core.database import Database +from imwald.core.forest_avatar import build_forest_avatar_png, build_forest_banner_png from imwald.core.kind0_profile import display_name_from_profile_or_hex, parse_kind0_profile from imwald.core.profile_lnurl import build_merged_lnurl_pay_section, collect_unique_lnurlp_urls from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary from imwald.core.nip19 import encode_npub from imwald.core.nostr_engine import NostrEngine from imwald.core.relay_list import parse_kind10002_tags +from imwald.ui.media_viewer_dialog import try_open_media_url_in_app from imwald.ui.note_text_browser import NoteTextBrowser -from imwald.ui.theme import BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED +from imwald.ui.theme import ACCENT_SOFT, BG_CARD, BG_CODE, BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED # Notes to list under “Recent in local DB” (feed-shaped kinds). _PROFILE_NOTE_KINDS: tuple[int, ...] = (1, 6, 20, 21, 30023, 9802, 11) +PROFILE_HERO_BANNER_H = 240 +PROFILE_HERO_AVATAR = 136 +PROFILE_HERO_OVERLAP = 0.48 # fraction of avatar height drawn above the banner bottom edge + + +def _cover_pixmap(src: QPixmap, tw: int, th: int) -> QPixmap: + if src.isNull() or tw < 2 or th < 2: + return QPixmap() + sc = src.scaled( + tw, + th, + Qt.AspectRatioMode.KeepAspectRatioByExpanding, + Qt.TransformationMode.SmoothTransformation, + ) + sw, sh = sc.width(), sc.height() + x = max(0, (sw - tw) // 2) + y = max(0, (sh - th) // 2) + return sc.copy(x, y, tw, th) + + +def _square_center_crop(pm: QPixmap) -> QPixmap: + if pm.isNull(): + return QPixmap() + s = min(pm.width(), pm.height()) + if s < 2: + return pm + x = (pm.width() - s) // 2 + y = (pm.height() - s) // 2 + return pm.copy(x, y, s, s) + + +def _circle_pixmap(src: QPixmap, diameter: int) -> QPixmap: + if src.isNull() or diameter < 4: + return QPixmap() + scaled = src.scaled( + diameter, + diameter, + Qt.AspectRatioMode.KeepAspectRatioByExpanding, + Qt.TransformationMode.SmoothTransformation, + ) + sw, sh = scaled.width(), scaled.height() + x = max(0, (sw - diameter) // 2) + y = max(0, (sh - diameter) // 2) + inner = scaled.copy(x, y, diameter, diameter) + out = QPixmap(diameter, diameter) + out.fill(Qt.GlobalColor.transparent) + painter = QPainter(out) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + path = QPainterPath() + path.addEllipse(0, 0, diameter, diameter) + painter.setClipPath(path) + painter.drawPixmap(0, 0, inner) + painter.end() + return out + + +class _BannerLabel(QLabel): + """Opens the remote banner URL in the system browser when set.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._open_url: str | None = None + self.setCursor(Qt.CursorShape.PointingHandCursor) + + def set_open_url(self, url: str | None) -> None: + self._open_url = url.strip() if url else None + self.setCursor( + Qt.CursorShape.PointingHandCursor if self._open_url else Qt.CursorShape.ArrowCursor + ) + + def mousePressEvent(self, event: QMouseEvent) -> None: + if self._open_url and event.button() == Qt.MouseButton.LeftButton: + q = QUrl(self._open_url) + if not try_open_media_url_in_app(self.window(), q): + QDesktopServices.openUrl(q) + super().mousePressEvent(event) + + +class ProfileHeroFrame(QFrame): + """Full-width cover + circular avatar overlapping the lower banner (social-style header).""" + + BANNER_H = PROFILE_HERO_BANNER_H + AVATAR = PROFILE_HERO_AVATAR + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("ProfileHeroFrame") + self._banner_src = QPixmap() + self._disp = QLabel(self) + self._npub = QLabel(self) + self._pk = QLabel(self) + self._nip05 = QLabel(self) + self._meta = QLabel(self) + self._banner_lbl = _BannerLabel(self) + self._banner_lbl.setScaledContents(False) + self._avatar_lbl = QLabel(self) + self._avatar_lbl.setFixedSize(self.AVATAR, self.AVATAR) + self._avatar_lbl.setScaledContents(False) + self._avatar_lbl.setStyleSheet( + f"QLabel {{ border: 3px solid {BORDER}; border-radius: {self.AVATAR // 2}px; " + f"background-color: {BG_CODE}; }}" + ) + for lb in (self._disp, self._npub, self._pk, self._nip05, self._meta): + lb.setWordWrap(True) + lb.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self._disp.setStyleSheet(f"color:{TEXT}; font-size:26px; font-weight:700;") + self._npub.setStyleSheet(f"color:{TEXT_MUTED}; font-size:14px;") + self._pk.setStyleSheet(f"color:{TEXT_DIM}; font-size:12px;") + self._nip05.setStyleSheet(f"color:{TEXT_MUTED}; font-size:15px;") + self._meta.setStyleSheet(f"color:{TEXT_DIM}; font-size:12px;") + self._relayout() + + def set_banner_source(self, pm: QPixmap) -> None: + self._banner_src = pm if not pm.isNull() else QPixmap() + self._relayout() + + def set_banner_open_url(self, url: str | None) -> None: + self._banner_lbl.set_open_url(url) + + def set_avatar_pixmap(self, pm: QPixmap) -> None: + if pm.isNull(): + self._avatar_lbl.clear() + else: + self._avatar_lbl.setPixmap(pm) + + def set_identity( + self, + display: str, + npub: str, + pubkey_hex: str, + nip05: str | None, + k0_meta: str | None, + ) -> None: + self._disp.setText(display) + self._npub.setText(npub) + self._pk.setText(pubkey_hex) + self._nip05.setText(nip05 or "") + self._nip05.setVisible(bool(nip05 and nip05.strip())) + self._meta.setText(k0_meta or "") + self._meta.setVisible(bool(k0_meta and k0_meta.strip())) + self._relayout() + + def minimumSizeHint(self) -> QSize: # noqa: N802 + overlap = int(self.AVATAR * PROFILE_HERO_OVERLAP) + h = self.BANNER_H + self.AVATAR - overlap + 18 + return QSize(280, h) + + def resizeEvent(self, event: QResizeEvent) -> None: + self._relayout() + super().resizeEvent(event) + + def _relayout(self) -> None: + w = max(self.width(), 80) + bh = self.BANNER_H + av = self.AVATAR + cover = _cover_pixmap(self._banner_src, w, bh) + self._banner_lbl.setPixmap(cover) + self._banner_lbl.setGeometry(0, 0, w, bh) + ax = 22 + ay = bh - int(av * PROFILE_HERO_OVERLAP) + self._avatar_lbl.setGeometry(ax, ay, av, av) + tx = ax + av + 16 + tw = w - tx - 16 + y0 = ay + 4 + self._disp.setGeometry(tx, y0, tw, 34) + self._npub.setGeometry(tx, y0 + 36, tw, 44) + self._pk.setGeometry(tx, y0 + 82, tw, 36) + y1 = y0 + 118 + if self._nip05.isVisible(): + self._nip05.setGeometry(tx, y1, tw, 28) + y1 += 30 + if self._meta.isVisible(): + self._meta.setGeometry(tx, y1, tw, 40) + overlap = int(av * PROFILE_HERO_OVERLAP) + total_h = bh + av - overlap + 14 + self.setFixedHeight(max(total_h, self.minimumSizeHint().height())) + self._avatar_lbl.raise_() + + +class _CoverImageSignals(QObject): + banner_ready = Signal(object, int) + avatar_ready = Signal(object, int) + + +class _ProfileHttpImageRunnable(QRunnable): + def __init__(self, url: str, gen: int, mode: str, out: _CoverImageSignals) -> None: + super().__init__() + self._url = url + self._gen = gen + self._mode = mode + self._out = out + + def run(self) -> None: + empty = QPixmap() + pm = QPixmap() + try: + from urllib.request import Request, urlopen + + req = Request(self._url, headers={"User-Agent": "imwald/1"}, method="GET") + with urlopen(req, timeout=16) as resp: # noqa: S310 + data = resp.read(6 * 1024 * 1024 + 1) + if len(data) > 6 * 1024 * 1024 or not pm.loadFromData(data): + self._emit_fail() + return + except OSError: + self._emit_fail() + return + if self._mode == "banner": + self._out.banner_ready.emit(pm, self._gen) + elif self._mode == "avatar": + sq = _square_center_crop(pm) + if sq.isNull(): + self._out.avatar_ready.emit(empty, self._gen) + return + scaled = sq.scaled( + 400, + 400, + Qt.AspectRatioMode.IgnoreAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + circ = _circle_pixmap(scaled, ProfileHeroFrame.AVATAR) + self._out.avatar_ready.emit(circ, self._gen) + else: + self._emit_fail() + + def _emit_fail(self) -> None: + if self._mode == "banner": + self._out.banner_ready.emit(QPixmap(), self._gen) + else: + self._out.avatar_ready.emit(QPixmap(), self._gen) + + +def _sec_title(label: str) -> str: + return ( + f'
{html.escape(label)}
' + ) + + +def _fmt_event_time(created_at: int | float | str | None) -> str: + if created_at is None: + return "—" + try: + ts = int(created_at) + except (TypeError, ValueError): + return str(created_at) + try: + return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d · %H:%M UTC") + except (OSError, OverflowError, ValueError): + return str(ts) + class _ProfileLnurlSignals(QObject): finished = Signal(str, int) @@ -45,7 +297,7 @@ class _ProfileLnurlRunnable(QRunnable): class ProfilePage(QWidget): """One pubkey: metadata, NIP-65 relays, follows (kind 3), emoji inventory, raw JSON, recent notes.""" - open_note = Signal(str) + open_note_new_tab = Signal(str) open_profile = Signal(str) def __init__( @@ -60,25 +312,50 @@ class ProfilePage(QWidget): self._db = db self._engine = engine self._pubkey = pubkey_hex.strip().lower() - scroll = QScrollArea(self) - scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.Shape.NoFrame) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self._body = NoteTextBrowser() - self._body.setObjectName("ProfileBody") - self._body.setOpenLinks(False) - self._body.setOpenExternalLinks(False) - self._body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self._body.anchorClicked.connect(self._on_anchor) - scroll.setWidget(self._body) + self._hero = ProfileHeroFrame(self) + self._left_body = NoteTextBrowser() + self._left_body.setObjectName("ProfileBodyLeft") + self._left_body.setOpenLinks(False) + self._left_body.setOpenExternalLinks(False) + self._left_body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._left_body.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._left_body.anchorClicked.connect(self._dispatch_profile_anchor) + self._feed_body = NoteTextBrowser() + self._feed_body.setObjectName("ProfileBodyFeed") + self._feed_body.setOpenLinks(False) + self._feed_body.setOpenExternalLinks(False) + self._feed_body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._feed_body.anchorClicked.connect(self._dispatch_profile_anchor) + self._left_column = QWidget(self) + left_lay = QVBoxLayout(self._left_column) + left_lay.setContentsMargins(0, 0, 0, 0) + left_lay.setSpacing(0) + left_lay.addWidget(self._hero, 0) + left_lay.addWidget(self._left_body, 1) + split = QSplitter(Qt.Orientation.Horizontal, self) + split.setObjectName("ProfileSplit") + split.setChildrenCollapsible(False) + split.addWidget(self._left_column) + split.addWidget(self._feed_body) + split.setStretchFactor(0, 100) + split.setStretchFactor(1, 58) + split.setSizes([720, 400]) + self._feed_body.setMinimumWidth(260) lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) - lay.addWidget(scroll) + lay.addWidget(split) self._lnurl_gen = 0 self._lnurl_sigs = _ProfileLnurlSignals(self) self._lnurl_sigs.finished.connect(self._on_lnurl_profile_ready) self._lnurl_pool = QThreadPool(self) self._lnurl_pool.setMaxThreadCount(1) + self._cover_banner_gen = 0 + self._cover_avatar_gen = 0 + self._cov_sigs = _CoverImageSignals(self) + self._cov_sigs.banner_ready.connect(self._on_cover_banner_http) + self._cov_sigs.avatar_ready.connect(self._on_cover_avatar_http) + self._cov_pool = QThreadPool(self) + self._cov_pool.setMaxThreadCount(3) self.refresh() def tab_title(self) -> str: @@ -92,6 +369,18 @@ class ProfilePage(QWidget): return self.refresh(from_lnurl=True, lnurl_html=html) + def _on_cover_banner_http(self, pm: object, gen: int) -> None: + if gen != self._cover_banner_gen: + return + if isinstance(pm, QPixmap) and not pm.isNull(): + self._hero.set_banner_source(pm) + + def _on_cover_avatar_http(self, pm: object, gen: int) -> None: + if gen != self._cover_avatar_gen: + return + if isinstance(pm, QPixmap) and not pm.isNull(): + self._hero.set_avatar_pixmap(pm) + def refresh(self, *, from_lnurl: bool = False, lnurl_html: str | None = None) -> None: if not from_lnurl: self._engine.enqueue_author_metadata(self._pubkey) @@ -129,35 +418,57 @@ class ProfilePage(QWidget): gen = self._lnurl_gen self._lnurl_pool.start(_ProfileLnurlRunnable(lnurls, gen, self._lnurl_sigs)) live_lnurl = f"

Fetching LNURL-pay metadata…

" - pay_block = "" + pay_card = "" if pay_static or live_lnurl: - pay_block = ( - f"

Lightning (NIP-57)

" - f"
{pay_static}{live_lnurl}
" - ) + pay_card = f"{_sec_title('Lightning · NIP-57')}
{pay_static}{live_lnurl}
" - disp = html.escape(display_name_from_profile_or_hex(parsed, pk)) - prof_href = html.escape(f"imwald://pub/{pk}", quote=True) - av = avatar_img_or_placeholder(parsed, 72, border_hex=BORDER, profile_href=prof_href) - nip05 = html.escape((parsed.get("nip05") or "").strip()) if parsed.get("nip05") else "" - nip05_html = ( - f"
{nip05}
" if nip05 else "" + disp_plain = display_name_from_profile_or_hex(parsed, pk) + nip05_plain = (parsed.get("nip05") or "").strip() if parsed.get("nip05") else "" + banner_raw = parsed.get("banner") + banner_https = ( + str(banner_raw).strip() + if banner_raw and str(banner_raw).strip().startswith("https://") + else "" + ) + picture_raw = parsed.get("picture") + picture_https = ( + str(picture_raw).strip() + if picture_raw and str(picture_raw).strip().startswith("https://") + else "" ) - banner = parsed.get("banner") - banner_html = "" - if banner and str(banner).strip().startswith("https://"): - bu = html.escape(str(banner).strip(), quote=True) - bw = IMAGE_DISPLAY_MAX_WIDTH_PX - banner_html = ( - f"
" - f'' - f'' - f"
" + forest_seed = int(pk[:16], 16) % (2**31) if len(pk) >= 16 else 0 + ph_b = QPixmap() + bh_bytes = build_forest_banner_png( + width=2000, height=ProfileHeroFrame.BANNER_H + 48, seed=forest_seed + ) + if not ph_b.loadFromData(bh_bytes): + ph_b.loadFromData( + build_forest_banner_png(width=2000, height=ProfileHeroFrame.BANNER_H + 48, seed=42) + ) + self._hero.set_banner_source(ph_b if not ph_b.isNull() else QPixmap()) + ph_a = QPixmap() + if not ph_a.loadFromData(build_forest_avatar_png(size=384, seed=forest_seed)): + ph_a.loadFromData(build_forest_avatar_png(size=384, seed=42)) + av_circ = _circle_pixmap(ph_a, ProfileHeroFrame.AVATAR) + self._hero.set_avatar_pixmap(av_circ) + self._cover_banner_gen += 1 + b_gen = self._cover_banner_gen + self._cover_avatar_gen += 1 + a_gen = self._cover_avatar_gen + if banner_https: + self._cov_pool.start(_ProfileHttpImageRunnable(banner_https, b_gen, "banner", self._cov_sigs)) + if picture_https: + self._cov_pool.start(_ProfileHttpImageRunnable(picture_https, a_gen, "avatar", self._cov_sigs)) + self._hero.set_banner_open_url(banner_https or None) + k0_meta_plain = "" + if k0_ev: + k0_meta_plain = ( + f"Kind 0 id {str(k0_ev['id'])[:20]}… · profile updated {_fmt_event_time(created0)}" ) + self._hero.set_identity(disp_plain, npub, pk, nip05_plain or None, k0_meta_plain or None) about_raw = (parsed.get("about") or "").strip() - about_md = "" + about_inner = "" if about_raw: frag = markdown_html_fragment( about_raw, @@ -165,7 +476,7 @@ class ProfilePage(QWidget): nip30_tags=tags0 or None, nip30_author_pubkey=pk, ) - about_md = f"

About

{frag}
" + about_inner = f"{_sec_title('About')}
{frag}
" raw_json = "" try: @@ -175,27 +486,27 @@ class ProfilePage(QWidget): except json.JSONDecodeError: raw_json = content or "" raw_esc = html.escape(raw_json[:12000] + ("…" if len(raw_json) > 12000 else ""), quote=False) - json_block = ( - f"

Kind 0 JSON (full)

" - f"
{raw_esc}
" + json_inner = ( + f"{_sec_title('Kind 0 JSON')}" + f"
{raw_esc}
" ) k10002 = self._db.get_latest_kind10002_event(pk) - relay_html = ( - f"

" + relay_inner = ( + f"

" f"No NIP-65 relay list (kind 10002) in local DB yet.

" ) if k10002: reads, writes = parse_kind10002_tags(k10002.get("tags") or []) r_esc = "
".join(html.escape(u) for u in reads[:40]) w_esc = "
".join(html.escape(u) for u in writes[:40]) - relay_html = ( - f"

Relays (NIP-65, kind 10002)

" - f"

Read

" - f"
{r_esc or '—'}
" - f"

Write

" - f"
{w_esc or '—'}
" + relay_inner = ( + f"{_sec_title('Relays · NIP-65 (kind 10002)')}" + f"

Read

" + f"
{r_esc or '—'}
" + f"

Write

" + f"
{w_esc or '—'}
" ) follows = self._db.get_latest_kind3_contact_pubkeys(pk, limit=400) @@ -209,9 +520,9 @@ class ProfilePage(QWidget): f' · {html.escape(fp[:16])}…' ) _no_follow = f"No kind 3 in local DB." - follow_block = ( - f"

Following (kind 3, local snapshot)

" - f"
{''.join(follow_lines) or _no_follow}
" + follow_inner = ( + f"{_sec_title('Following · kind 3 (local)')}" + f"
{''.join(follow_lines) or _no_follow}
" ) nip30 = self._db.get_author_nip30_emoji_urls(pk) @@ -222,10 +533,7 @@ class ProfilePage(QWidget): f'{html.escape(url[:48])}…' ) _no_emoji = f"No emoji packs indexed yet." - emoji_block = ( - f"

Custom emoji (NIP-30, local)

" - f"
{''.join(em_lines) or _no_emoji}
" - ) + emoji_inner = f"{_sec_title('Custom emoji · NIP-30')}" f"
{''.join(em_lines) or _no_emoji}
" notes = self._db.list_events_by_pubkey(pk, kinds=_PROFILE_NOTE_KINDS, limit=40) note_lines: list[str] = [] @@ -235,62 +543,73 @@ class ProfilePage(QWidget): nip = cast(list[list[str]], ev["tags"]) if isinstance(ev.get("tags"), list) else None snip = markdown_plain_summary( ev.get("content") or "", - max_len=72, + max_len=96, db=self._db, nip30_tags=nip, nip30_author_pubkey=str(ev.get("pubkey") or "") or None, ) + t_human = _fmt_event_time(ev.get("created_at")) + kind_lbl = int(ev["kind"]) + esc_href = html.escape(href, quote=True) note_lines.append( - f'
' - f"k{int(ev['kind'])} · {int(ev['created_at'])}
" - f'' - f"Open in feed" - f"
{html.escape(snip)}
" - "
" + f'' + f"
" + f"Kind {kind_lbl} · {html.escape(t_human)}
" + f"
" + f"{html.escape(snip)}
" + f"
" + f"Open in new tab →
" ) - _no_notes = f"No matching notes stored yet." - notes_block = ( - f"

Recent notes (local DB)

" - f"{''.join(note_lines) or _no_notes}" + _no_notes = "No matching notes stored yet." + + card_style = ( + f"background:{BG_CARD};border:1px solid {BORDER};border-radius:14px;" + f"padding:16px 18px;margin-bottom:14px" ) + left_parts: list[str] = [] + if about_inner: + left_parts.append(f'
{about_inner}
') + if pay_card: + left_parts.append(f'
{pay_card}
') + left_parts.append(f'
{relay_inner}
') + left_parts.append(f'
{follow_inner}
') + left_parts.append(f'
{emoji_inner}
') + left_parts.append(f'
{json_inner}
') - k0_meta = "" - if k0_ev: - k0_meta = ( - f"

Kind 0 event id: {html.escape(str(k0_ev['id']))}" - f" · updated {created0}

" - ) + left_doc = ( + "" + f"{FEED_DOC_CSS}" + f"{''.join(left_parts)}" + ) - doc = ( + n_notes = len(notes) + feed_intro = ( + f'
' + f'
Posts
' + f'
' + f"{n_notes} recent note{'' if n_notes == 1 else 's'} indexed on this device. " + f"Click a card to open the thread in a new tab.
" + ) + if note_lines: + feed_inner = "".join(note_lines) + else: + feed_inner = f"

{html.escape(_no_notes)}

" + feed_doc = ( "" - f"{FEED_DOC_CSS}" - f"{banner_html}" - f"
" - f"{av}" - f"
" - f"
{disp}
" - f"
{html.escape(npub)}
" - f"
{html.escape(pk[:24])}…
" - f"{nip05_html}" - f"
" - f"{k0_meta}" - f"{about_md}" - f"{pay_block}" - f"{relay_html}" - f"{follow_block}" - f"{emoji_block}" - f"{json_block}" - f"{notes_block}" - "" + f"{FEED_DOC_CSS}" + f"{feed_intro}{feed_inner}" ) - self._body.setHtml(doc) + self._left_body.setHtml(left_doc) + self._feed_body.setHtml(feed_doc) tw = self.parentWidget() if isinstance(tw, QTabWidget): i = tw.indexOf(self) if i >= 0: tw.setTabText(i, self.tab_title()) - def _on_anchor(self, url: QUrl) -> None: + def _dispatch_profile_anchor(self, url: QUrl) -> None: s = url.toString() if url.scheme() == "imwald" and url.host() == "pub": tail = (url.path() or "").strip("/").lower() @@ -300,7 +619,8 @@ class ProfilePage(QWidget): if url.scheme() == "imwald" and url.host() == "note": eid = (url.path() or "").strip("/") if len(eid) == 64 and all(c in "0123456789abcdef" for c in eid.lower()): - self.open_note.emit(eid.lower()) + self.open_note_new_tab.emit(eid.lower()) return if s.startswith("https://") or s.startswith("http://"): - QDesktopServices.openUrl(url) + if not try_open_media_url_in_app(self.window(), url): + QDesktopServices.openUrl(url) diff --git a/src/imwald/ui/theme.py b/src/imwald/ui/theme.py index 33fde54..b5378a1 100644 --- a/src/imwald/ui/theme.py +++ b/src/imwald/ui/theme.py @@ -113,24 +113,55 @@ QHeaderView::section {{ }} QTabWidget::pane {{ border: 1px solid {BORDER}; - border-radius: 8px; + border-radius: 10px; background-color: {BG_FIELD}; top: -1px; + padding: 2px; +}} +QTabBar {{ + qproperty-drawBase: 0; + background-color: transparent; }} QTabBar::tab {{ background-color: {BG_CARD}; - color: {TEXT_MUTED}; - padding: 8px 16px; - margin-right: 2px; + color: {TEXT_DIM}; + font-size: 15px; + min-height: 22px; + padding: 10px 22px 11px 20px; + margin: 8px 5px 0 0; border: 1px solid {BORDER}; border-bottom: none; - border-top-left-radius: 8px; - border-top-right-radius: 8px; + border-top-left-radius: 10px; + border-top-right-radius: 10px; }} QTabBar::tab:selected {{ background-color: {BG_FIELD}; - color: {ACCENT}; + color: {TEXT}; font-weight: 600; + padding-bottom: 12px; + margin-bottom: -1px; + border-color: {BORDER}; + border-bottom-color: {BG_FIELD}; +}} +QTabBar::tab:!selected:hover {{ + background-color: {BG_CODE}; + color: {TEXT}; +}} +QTabBar::close-button {{ + subcontrol-origin: padding; + subcontrol-position: right; + width: 22px; + height: 22px; + margin: 0 2px 0 8px; + padding: 4px; + border-radius: 6px; +}} +QTabBar::close-button:hover {{ + background-color: {BG_CARD}; + border: 1px solid {ACCENT_SOFT}; +}} +QTabBar::close-button:pressed {{ + background-color: {BG_CODE}; }} QComboBox {{ background-color: {BG_FIELD}; diff --git a/tests/test_forest_avatar.py b/tests/test_forest_avatar.py new file mode 100644 index 0000000..5e3f6f0 --- /dev/null +++ b/tests/test_forest_avatar.py @@ -0,0 +1,20 @@ +"""Forest placeholder art bytes.""" + +from imwald.core.forest_avatar import build_forest_avatar_png, build_forest_banner_png + + +def test_forest_avatar_png_magic() -> None: + b = build_forest_avatar_png() + assert b.startswith(b"\x89PNG\r\n\x1a\n") + + +def test_forest_banner_png_magic_and_seed_variation() -> None: + a = build_forest_banner_png(seed=1) + b = build_forest_banner_png(seed=2) + assert a.startswith(b"\x89PNG\r\n\x1a\n") + assert b.startswith(b"\x89PNG\r\n\x1a\n") + assert a != b + + +def test_forest_avatar_seed_changes_bytes() -> None: + assert build_forest_avatar_png(seed=1) != build_forest_avatar_png(seed=2) diff --git a/tests/test_media_viewer.py b/tests/test_media_viewer.py new file mode 100644 index 0000000..f0bffe2 --- /dev/null +++ b/tests/test_media_viewer.py @@ -0,0 +1,16 @@ +"""Media URL classification for the in-app viewer.""" + +from PySide6.QtCore import QUrl + +from imwald.ui.media_viewer_dialog import classify_media_url + + +def test_classify_by_extension() -> None: + assert classify_media_url(QUrl("https://cdn/x/photo.webp")) == "image" + assert classify_media_url(QUrl("https://x.com/a/b.MP4?q=1")) == "video" + assert classify_media_url(QUrl("https://h/audio.opus")) == "audio" + + +def test_classify_unknown() -> None: + assert classify_media_url(QUrl("https://x.com/page")) is None + assert classify_media_url(QUrl("imwald://pub/abcd")) is None diff --git a/tests/test_nip05_collect.py b/tests/test_nip05_collect.py new file mode 100644 index 0000000..6159a78 --- /dev/null +++ b/tests/test_nip05_collect.py @@ -0,0 +1,21 @@ +"""NIP-05 identifier collection and parsing.""" + +from imwald.core.kind0_profile import collect_nip05_identifiers +from imwald.core.nip05 import parse_nip05_identifier + + +def test_parse_nip05_identifier() -> None: + assert parse_nip05_identifier("bob@Example.com") == ("bob", "example.com") + assert parse_nip05_identifier("not an id") is None + + +def test_collect_from_json_and_tags_and_scan() -> None: + content = '{"name":"x","nip05":"a@b.co"} extra@scan.org tail' + tags: list[list[str]] = [["nip05", "c@d.co"], ["nip-05", "e@f.co"], ["client", "x"]] + got = collect_nip05_identifiers(content, tags) + assert got == ["a@b.co", "c@d.co", "e@f.co", "extra@scan.org"] + + +def test_collect_dedupes() -> None: + content = '{"nip05":"Same@X.org"} same@x.org' + assert collect_nip05_identifiers(content, []) == ["Same@X.org"]