From c26a3768d2a3967b122a274d7174ee3b25590ab2 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 19 Apr 2026 14:00:17 +0200 Subject: [PATCH] profile page --- src/imwald/core/author_html.py | 19 +- src/imwald/core/database.py | 110 ++++++++++++ src/imwald/core/kind0_profile.py | 4 + src/imwald/core/profile_lnurl.py | 226 +++++++++++++++++++++++ src/imwald/ui/feed_page.py | 25 ++- src/imwald/ui/main_window.py | 60 ++++++- src/imwald/ui/profile_page.py | 300 +++++++++++++++++++++++++++++++ tests/test_profile_lnurl.py | 73 ++++++++ 8 files changed, 805 insertions(+), 12 deletions(-) create mode 100644 src/imwald/core/profile_lnurl.py create mode 100644 src/imwald/ui/profile_page.py create mode 100644 tests/test_profile_lnurl.py diff --git a/src/imwald/core/author_html.py b/src/imwald/core/author_html.py index ac381c3..780689a 100644 --- a/src/imwald/core/author_html.py +++ b/src/imwald/core/author_html.py @@ -43,17 +43,20 @@ def feed_op_author_block_html( nip_line_html: str, about_line_html: str, *, + pubkey_hex: str, text: str, muted: str, dim: str, border: str, ) -> str: - """Top-of-note author row: picture, display name, npub, optional nip05/about lines.""" + """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)) av = avatar_img_or_placeholder(parsed, 52, border_hex=border) npub_e = html.escape(npub_bech) pk_s = html.escape(pk_short) - return ( + pk_l = pubkey_hex.strip().lower() + href = html.escape(f"imwald://pub/{pk_l}", quote=True) + inner = ( f'
' f"{av}" f'
' @@ -62,6 +65,10 @@ def feed_op_author_block_html( f"{nip_line_html}{about_line_html}" f"
" ) + return ( + f'{inner}' + ) def thread_reply_author_row_html( @@ -79,7 +86,9 @@ def thread_reply_author_row_html( av = avatar_img_or_placeholder(parsed, 40, border_hex=border) name = html.escape(display_name_from_profile_or_hex(parsed, pubkey_hex)) npub_e = html.escape(npub_bech) - return ( + pk_l = pubkey_hex.strip().lower() + href = html.escape(f"imwald://pub/{pk_l}", quote=True) + inner = ( '
' f"{av}" '
' @@ -88,6 +97,10 @@ def thread_reply_author_row_html( f'{npub_e}' "
" ) + return ( + f'{inner}' + ) def inline_profile_badge_html(parsed: dict[str, str | None], pubkey_hex: str, npub_tooltip: str, badge_style: str) -> str: diff --git a/src/imwald/core/database.py b/src/imwald/core/database.py index 225202e..ac979be 100644 --- a/src/imwald/core/database.py +++ b/src/imwald/core/database.py @@ -400,6 +400,116 @@ class Database: "tags": cast(list[list[str]], json.loads(row["tags_json"] or "[]")), } + def get_latest_kind0_event(self, pubkey: str) -> StoredEventRow | None: + """Latest non-deleted kind 0 for ``pubkey`` (full row including ``tags``).""" + cur = self.conn().execute( + """ + SELECT id, pubkey, created_at, kind, content, sig, tags_json, deleted, source_relay + FROM events + WHERE deleted = 0 AND kind = 0 AND lower(pubkey) = lower(?) + ORDER BY created_at DESC LIMIT 1 + """, + (pubkey.strip().lower(),), + ) + 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"], + } + + def get_latest_kind3_contact_pubkeys(self, pubkey: str, *, limit: int = 512) -> list[str]: + """Hex pubkeys from ``p`` tags on this author's latest kind 3 contact list (local DB).""" + cur = self.conn().execute( + """ + SELECT tags_json FROM events + WHERE deleted = 0 AND kind = 3 AND lower(pubkey) = lower(?) + ORDER BY created_at DESC LIMIT 1 + """, + (pubkey.strip().lower(),), + ) + row = cur.fetchone() + if not row: + return [] + try: + tags_raw = json.loads(row["tags_json"] or "[]") + except json.JSONDecodeError: + return [] + if not isinstance(tags_raw, list): + return [] + out: list[str] = [] + for t_obj in cast(list[object], tags_raw): + if not isinstance(t_obj, list): + continue + tr = cast(list[object], t_obj) + if len(tr) < 2: + continue + if str(tr[0]) != "p": + continue + pk = str(tr[1] or "").strip().lower() + if len(pk) == 64 and all(c in "0123456789abcdef" for c in pk): + out.append(pk) + if len(out) >= limit: + break + return out + + def list_events_by_pubkey( + self, + pubkey: str, + *, + kinds: Sequence[int] | None = None, + limit: int = 80, + ) -> list[dict[str, Any]]: + """Recent non-deleted events by author, optionally filtered by ``kinds``.""" + pk = pubkey.strip().lower() + if len(pk) != 64 or any(c not in "0123456789abcdef" for c in pk): + return [] + if kinds: + ph = ",".join("?" * len(kinds)) + cur = self.conn().execute( + f""" + SELECT id, pubkey, created_at, kind, content, sig, tags_json + FROM events + WHERE deleted = 0 AND lower(pubkey) = lower(?) AND kind IN ({ph}) + ORDER BY created_at DESC, id DESC + LIMIT ? + """, + (pk, *kinds, limit), + ) + else: + cur = self.conn().execute( + """ + SELECT id, pubkey, created_at, kind, content, sig, tags_json + FROM events + WHERE deleted = 0 AND lower(pubkey) = lower(?) + ORDER BY created_at DESC, id DESC + LIMIT ? + """, + (pk, limit), + ) + rows: list[dict[str, Any]] = [] + for r in cur: + rows.append( + { + "id": r["id"], + "pubkey": r["pubkey"], + "created_at": r["created_at"], + "kind": r["kind"], + "content": r["content"], + "sig": r["sig"], + "tags": cast(list[list[str]], json.loads(r["tags_json"] or "[]")), + } + ) + return rows + 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=?", diff --git a/src/imwald/core/kind0_profile.py b/src/imwald/core/kind0_profile.py index 4542f1f..ef1872d 100644 --- a/src/imwald/core/kind0_profile.py +++ b/src/imwald/core/kind0_profile.py @@ -15,6 +15,8 @@ def parse_kind0_profile(content: str) -> dict[str, str | None]: "picture": None, "nip05": None, "banner": None, + "lud06": None, + "lud16": None, } try: raw = json.loads(content or "") @@ -37,6 +39,8 @@ def parse_kind0_profile(content: str) -> dict[str, str | None]: empty["picture"] = pick("picture", "avatar", "image") empty["nip05"] = pick("nip05") empty["banner"] = pick("banner") + empty["lud06"] = pick("lud06", "lnurl") + empty["lud16"] = pick("lud16", "lightningAddress", "lightning_address") return empty diff --git a/src/imwald/core/profile_lnurl.py b/src/imwald/core/profile_lnurl.py new file mode 100644 index 0000000..c5c2020 --- /dev/null +++ b/src/imwald/core/profile_lnurl.py @@ -0,0 +1,226 @@ +"""Resolve kind-0 ``lud06`` / ``lud16`` to LNURL-pay URLs, fetch pay metadata, dedupe by callback (NIP-57 / LUD-06).""" + +from __future__ import annotations + +import html +import json +import re +from typing import Any, cast +from urllib.error import URLError +from urllib.request import Request, urlopen + +import bech32 + +_USER_AGENT = "imwald/1 (LNURL-pay profile fetch)" + + +def decode_lnurl_bech32(lud06: str) -> str | None: + """Decode ``lnurl1…`` (LUD-06) to the underlying HTTPS LNURL URL.""" + s = lud06.strip() + if not s: + return None + hrp, data = bech32.bech32_decode(s.lower()) + if hrp != "lnurl" or data is None: + return None + conv = bech32.convertbits(list(data), 5, 8, False) + if conv is None: + return None + try: + out = bytes(conv).decode("utf-8").strip() + except UnicodeDecodeError: + return None + return out if out.startswith("https://") else None + + +def lnurlp_url_from_lud16(lud16: str) -> str | None: + """ + Map ``lud16`` to an LNURL-pay **first request** URL. + + - Lightning address ``local@domain`` → ``https://domain/.well-known/lnurlp/local`` (LUD-16). + - Already ``https://…`` → returned as-is (trimmed). + """ + s = lud16.strip() + if not s: + return None + if s.startswith("https://"): + return s + if s.startswith("http://"): + return None + if "@" in s and not s.startswith("lnurl"): + local, domain = s.split("@", 1) + local, domain = local.strip(), domain.strip().lower() + if local and domain and re.fullmatch(r"[a-z0-9.-]+", domain): + return f"https://{domain}/.well-known/lnurlp/{local}" + return None + + +def _normalize_lnurlp_first_url(u: str) -> str: + """Stable key for deduplicating identical entry points (ignores trivial suffix differences).""" + return u.strip().rstrip("/").lower() + + +def normalize_callback(cb: str) -> str: + """Dedupe live metadata: same wallet often reachable via lud06 vs lud16.""" + c = cb.strip().split("?", 1)[0].rstrip("/").lower() + return c + + +def collect_unique_lnurlp_urls(lud06: str | None, lud16: str | None) -> list[str]: + """Ordered unique LNURL-pay **first-hop** URLs from kind 0 fields (lud06 then lud16).""" + seen: set[str] = set() + out: list[str] = [] + for raw in (lud06, lud16): + if not raw: + continue + u: str | None = None + t = raw.strip() + if t.lower().startswith("lnurl"): + u = decode_lnurl_bech32(t) + elif "@" in t or t.startswith("https://"): + u = lnurlp_url_from_lud16(t) + if not u: + continue + k = _normalize_lnurlp_first_url(u) + if k in seen: + continue + seen.add(k) + out.append(u.strip()) + return out + + +def fetch_lnurlp_pay_json(url: str, *, timeout: float = 14.0) -> dict[str, Any] | None: + """GET LNURL-pay first response; returns JSON object or ``None``.""" + try: + req = Request( + url, + headers={"User-Agent": _USER_AGENT, "Accept": "application/json"}, + method="GET", + ) + with urlopen(req, timeout=timeout) as resp: # noqa: S310 + blob = resp.read() + data = json.loads(blob.decode("utf-8")) + except (URLError, OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError): + return None + return cast(dict[str, Any], data) if isinstance(data, dict) else None + + +def _int_msat_field(v: object) -> int: + if isinstance(v, bool) or v is None: + return 0 + if isinstance(v, int): + return v + if isinstance(v, str): + try: + return int(v.strip()) + except ValueError: + return 0 + return 0 + + +def _msat_range_sats(min_msat: object, max_msat: object) -> str: + lo = _int_msat_field(min_msat) + hi = _int_msat_field(max_msat) + if lo <= 0 and hi <= 0: + return "unknown" + # LNURL-pay amounts are millisatoshis (1000 msat = 1 sat). + lo_s = lo / 1000.0 + hi_s = hi / 1000.0 + return f"{lo_s:.0f}–{hi_s:.0f} sat" if lo_s != hi_s else f"{lo_s:.0f} sat" + + +def _metadata_lines(meta: object) -> str: + if isinstance(meta, str): + try: + inner = json.loads(meta) + except json.JSONDecodeError: + return f"

{html.escape(meta[:500])}

" + if isinstance(inner, dict): + d = cast(dict[str, Any], inner) + parts: list[str] = [] + for key in ("long_description", "description", "image"): + v_raw = d.get(key) + if isinstance(v_raw, str) and v_raw.strip(): + v = v_raw.strip() + parts.append( + f"

{html.escape(key)}: {html.escape(v[:800])}

" + ) + return "".join(parts) if parts else "" + return "" + return "" + + +def format_lnurl_pay_html(source_url: str, doc: dict[str, Any]) -> str: + """Single payRequest document → HTML fragment.""" + if doc.get("tag") != "payRequest": + return f"

Unexpected tag {html.escape(str(doc.get('tag')))}

" + cb = str(doc.get("callback") or "") + ms = _msat_range_sats(doc.get("minSendable"), doc.get("maxSendable")) + allows = doc.get("allowsNostr") + npk = doc.get("nostrPubkey") + meta_html = _metadata_lines(doc.get("metadata")) + lines = [ + f"

Resolved from {html.escape(source_url[:96])}

", + f"

Amount range ({doc.get('minSendable')}–{doc.get('maxSendable')} msat): {html.escape(ms)}

", + f"

allowsNostr: {html.escape(str(allows))}   " + f"nostrPubkey: {html.escape(str(npk)[:80])}

", + f"

callback {html.escape(cb[:120])}

", + meta_html, + ] + return "".join(lines) + + +def build_merged_lnurl_pay_section(urls: list[str]) -> str: + """ + Fetch each unique URL, dedupe by ``callback`` host/path, aggregate min/max across merged group. + + Returns HTML for the profile page (empty string if nothing usable). + """ + if not urls: + return "" + by_cb: dict[str, list[tuple[str, dict[str, Any]]]] = {} + errors: list[str] = [] + for u in urls: + j = fetch_lnurlp_pay_json(u) + if not j: + errors.append(f"
  • Fetch failed or invalid JSON: {html.escape(u[:80])}
  • ") + continue + if j.get("tag") != "payRequest": + errors.append(f"
  • Not payRequest: {html.escape(u[:80])}
  • ") + continue + cb = str(j.get("callback") or "") + key = normalize_callback(cb) if cb else f"nocab:{_normalize_lnurlp_first_url(u)}" + by_cb.setdefault(key, []).append((u, j)) + + blocks: list[str] = [] + for _key, group in by_cb.items(): + # Aggregate millisats across aliases pointing at same callback + mins: list[int] = [] + maxs: list[int] = [] + first_doc: dict[str, Any] | None = None + sources: list[str] = [] + for src, d in group: + sources.append(src) + if first_doc is None: + first_doc = d + for fld, bucket in (("minSendable", mins), ("maxSendable", maxs)): + bucket.append(_int_msat_field(d.get(fld))) + if first_doc is None: + continue + agg = dict(first_doc) + if mins: + agg["minSendable"] = min(mins) + if maxs: + agg["maxSendable"] = max(maxs) + src_label = html.escape(" + ".join(s[:48] for s in sources)[:200]) + blocks.append( + f"
    " + f"

    Merged sources ({len(group)}): {src_label}

    " + f"{format_lnurl_pay_html(sources[0], agg)}" + f"
    " + ) + + err_html = f"" if errors else "" + if not blocks and not errors: + return "" + head = "

    Lightning (LNURL-pay, live)

    " + return head + "".join(blocks) + err_html diff --git a/src/imwald/ui/feed_page.py b/src/imwald/ui/feed_page.py index bad71d8..3b20b95 100644 --- a/src/imwald/ui/feed_page.py +++ b/src/imwald/ui/feed_page.py @@ -8,8 +8,8 @@ import re from collections.abc import Sequence from typing import Any, cast -from PySide6.QtCore import QEvent, QObject, Qt, QTimer -from PySide6.QtGui import QKeyEvent, QTextOption +from PySide6.QtCore import QEvent, QObject, Qt, QTimer, Signal, QUrl +from PySide6.QtGui import QDesktopServices, QKeyEvent, QTextOption from PySide6.QtWidgets import ( QFrame, @@ -135,6 +135,10 @@ def _format_engagement_html( class FeedPage(QWidget): + """Emitted when the user activates an ``imwald://pub/…`` profile link (OP or thread header).""" + + profile_requested = Signal(str) + def __init__(self, db: Database, engine: NostrEngine, parent: QWidget | None = None) -> None: super().__init__(parent) self.setObjectName("FeedPage") @@ -168,7 +172,9 @@ class FeedPage(QWidget): op_card_lay.setContentsMargins(10, 10, 10, 10) self._op = NoteTextBrowser() self._op.setObjectName("OpNote") - self._op.setOpenExternalLinks(True) + self._op.setOpenLinks(False) + self._op.setOpenExternalLinks(False) + self._op.anchorClicked.connect(self._on_feed_rich_anchor) self._op.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self._op.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self._op.installEventFilter(self) @@ -245,6 +251,16 @@ class FeedPage(QWidget): outer.setContentsMargins(10, 8, 10, 8) outer.addWidget(split) + def _on_feed_rich_anchor(self, url: QUrl) -> None: + if url.scheme() == "imwald" and url.host() == "pub": + pk = (url.path() or "").strip("/").lower() + if len(pk) == 64 and all(c in "0123456789abcdef" for c in pk): + self.profile_requested.emit(pk) + return + s = url.toString() + if s.startswith("https://") or s.startswith("http://"): + QDesktopServices.openUrl(url) + 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 ( @@ -427,6 +443,7 @@ class FeedPage(QWidget): pk_short, nip_line, about_line, + pubkey_hex=pk, text=TEXT, muted=TEXT_MUTED, dim=TEXT_DIM, @@ -480,7 +497,9 @@ class FeedPage(QWidget): rk = int(r["kind"]) head_b = NoteTextBrowser(self) head_b.setObjectName("ReplyHead") + head_b.setOpenLinks(False) head_b.setOpenExternalLinks(False) + head_b.anchorClicked.connect(self._on_feed_rich_anchor) head_b.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) head_b.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) head_b.setFrameShape(QFrame.Shape.NoFrame) diff --git a/src/imwald/ui/main_window.py b/src/imwald/ui/main_window.py index 1158139..d9b3000 100644 --- a/src/imwald/ui/main_window.py +++ b/src/imwald/ui/main_window.py @@ -18,6 +18,7 @@ from PySide6.QtWidgets import ( QMessageBox, QSplitter, QStackedWidget, + QTabWidget, QToolBar, QVBoxLayout, QWidget, @@ -33,6 +34,7 @@ from imwald.core.relay_policy import augment_feed_with_trending from imwald.ui.composer_dialog import ComposerDialog from imwald.ui.db_admin_page import DbAdminPage from imwald.ui.feed_page import FeedPage +from imwald.ui.profile_page import ProfilePage from imwald.ui.notifications_page import NotificationsPage from imwald.ui.onboarding_wizard import run_onboarding_wizard from imwald.ui.relay_status_panel import RelayStatusPanel @@ -102,11 +104,18 @@ class MainWindow(QMainWindow): self._stack = QStackedWidget() self._feed = FeedPage(db, engine) + self._browser_tabs = QTabWidget() + self._browser_tabs.setObjectName("BrowserTabs") + self._browser_tabs.setTabsClosable(True) + self._browser_tabs.setMovable(True) + self._browser_tabs.tabCloseRequested.connect(self._on_browser_tab_close) + self._browser_tabs.addTab(self._feed, "Feed") + self._profile_tabs_by_pubkey: dict[str, ProfilePage] = {} self._search = SearchPage(db) self._notif = NotificationsPage(db, self._accounts) self._dbadm = DbAdminPage(db, self._accounts) - self._stack.addWidget(self._feed) # 0 + self._stack.addWidget(self._browser_tabs) # 0 self._stack.addWidget(self._search) # 1 self._stack.addWidget(self._notif) # 2 self._stack.addWidget(self._dbadm) # 3 @@ -280,8 +289,12 @@ class MainWindow(QMainWindow): self._engine.enqueue_author_metadata_many(self._db.distinct_pubkeys_recent(450)) def _flush_ingest_ui_refresh(self) -> None: - if self._stack.currentWidget() is self._feed: - self._feed.refresh_tail() + if self._stack.currentIndex() == 0: + cur = self._browser_tabs.currentWidget() + if cur is self._feed: + self._feed.refresh_tail() + elif isinstance(cur, ProfilePage): + cur.refresh() self._notif.refresh_all() def _wire_menu(self) -> None: @@ -309,12 +322,12 @@ class MainWindow(QMainWindow): ): act = QAction(title, self) act.setData(idx) - act.triggered.connect(lambda checked=False, x=idx: self._stack.setCurrentIndex(x)) + act.triggered.connect(lambda checked=False, x=idx: self._go_stack_page(x)) m_view.addAction(act) m_tools = self.menuBar().addMenu("&Tools") a_db = QAction("&Local database…", self) - a_db.triggered.connect(lambda: self._stack.setCurrentIndex(3)) + a_db.triggered.connect(lambda: self._go_stack_page(3)) m_tools.addAction(a_db) m_help = self.menuBar().addMenu("&Help") @@ -353,7 +366,42 @@ class MainWindow(QMainWindow): self._engine.enqueue_author_metadata(pk) self._ingest_ui_timer.start() + def _go_stack_page(self, idx: int) -> None: + self._stack.setCurrentIndex(idx) + if idx == 0: + self._browser_tabs.setCurrentWidget(self._feed) + + def _open_profile_tab(self, pubkey_hex: str) -> None: + pk = pubkey_hex.strip().lower() + if len(pk) != 64 or any(c not in "0123456789abcdef" for c in pk): + return + self._go_stack_page(0) + existing = self._profile_tabs_by_pubkey.get(pk) + if existing is not None: + self._browser_tabs.setCurrentWidget(existing) + existing.refresh() + return + page = ProfilePage(self._db, self._engine, pk, self._browser_tabs) + page.open_note.connect(self._open_event) + page.open_profile.connect(self._open_profile_tab) + self._profile_tabs_by_pubkey[pk] = page + self._browser_tabs.addTab(page, page.tab_title()) + self._browser_tabs.setCurrentWidget(page) + + def _on_browser_tab_close(self, index: int) -> None: + if index <= 0: + return + w = self._browser_tabs.widget(index) + self._browser_tabs.removeTab(index) + if isinstance(w, ProfilePage): + for k, v in list(self._profile_tabs_by_pubkey.items()): + if v is w: + del self._profile_tabs_by_pubkey[k] + break + w.deleteLater() + def _wire_pages(self) -> None: + self._feed.profile_requested.connect(self._open_profile_tab) self._search.open_event.connect(self._open_event) self._notif.open_event.connect(self._open_event) self._notif.signing_pubkey_changed.connect(self._on_notif_signing) @@ -364,7 +412,7 @@ class MainWindow(QMainWindow): self.statusBar().showMessage(f"Notifications tab signing context: {pubkey[:16]}…", 5000) def _open_event(self, event_id: str) -> None: - self._stack.setCurrentIndex(0) + self._go_stack_page(0) self._feed.show_event(event_id) def _nip09_from_db(self, event_id: str, pubkey: str) -> None: diff --git a/src/imwald/ui/profile_page.py b/src/imwald/ui/profile_page.py new file mode 100644 index 0000000..6fbbae7 --- /dev/null +++ b/src/imwald/ui/profile_page.py @@ -0,0 +1,300 @@ +"""Full-screen profile view (kind 0, relays, follows, notes) opened in a browser tab.""" + +from __future__ import annotations + +import html +import json +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 imwald.core.author_html import avatar_img_or_placeholder +from imwald.core.database import Database +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.note_text_browser import NoteTextBrowser +from imwald.ui.theme import 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) + + +class _ProfileLnurlSignals(QObject): + finished = Signal(str, int) + + +class _ProfileLnurlRunnable(QRunnable): + def __init__(self, urls: list[str], gen: int, out: _ProfileLnurlSignals) -> None: + super().__init__() + self._urls = urls + self._gen = gen + self._out = out + + def run(self) -> None: + html = build_merged_lnurl_pay_section(self._urls) + self._out.finished.emit(html, self._gen) + + +class ProfilePage(QWidget): + """One pubkey: metadata, NIP-65 relays, follows (kind 3), emoji inventory, raw JSON, recent notes.""" + + open_note = Signal(str) + open_profile = Signal(str) + + def __init__( + self, + db: Database, + engine: NostrEngine, + pubkey_hex: str, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName("ProfilePage") + 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) + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(scroll) + 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.refresh() + + def tab_title(self) -> str: + row = self._db.get_latest_kind0_profile(self._pubkey) + p = parse_kind0_profile(row["content"] if row else "") + t = display_name_from_profile_or_hex(p, self._pubkey) + return t[:28] + ("…" if len(t) > 28 else "") + + def _on_lnurl_profile_ready(self, html: str, gen: int) -> None: + if gen != self._lnurl_gen: + return + self.refresh(from_lnurl=True, lnurl_html=html) + + def refresh(self, *, from_lnurl: bool = False, lnurl_html: str | None = None) -> None: + if not from_lnurl: + self._engine.enqueue_author_metadata(self._pubkey) + pk = self._pubkey + npub = encode_npub(pk) + k0_ev = self._db.get_latest_kind0_event(pk) + prof_row = self._db.get_latest_kind0_profile(pk) + content = prof_row["content"] if prof_row else "" + created0 = int(prof_row["created_at"]) if prof_row else 0 + parsed = parse_kind0_profile(content) + tags0: list[list[str]] | None = k0_ev["tags"] if k0_ev else None + + lud06_raw = parsed.get("lud06") + lud16_raw = parsed.get("lud16") + lud06_s = lud06_raw.strip() if isinstance(lud06_raw, str) else "" + lud16_s = lud16_raw.strip() if isinstance(lud16_raw, str) else "" + lnurls = collect_unique_lnurlp_urls(lud06_s or None, lud16_s or None) + pay_rows: list[str] = [] + if lud06_s: + pay_rows.append( + f"

    lud06 (LNURL / NIP-57): " + f"{html.escape(lud06_s[:200])}

    " + ) + if lud16_s: + pay_rows.append( + f"

    lud16 (Lightning address or HTTPS LNURL): " + f"{html.escape(lud16_s[:200])}

    " + ) + pay_static = "".join(pay_rows) + live_lnurl = "" + if from_lnurl and lnurl_html is not None: + live_lnurl = lnurl_html + elif not from_lnurl and lnurls: + self._lnurl_gen += 1 + gen = self._lnurl_gen + self._lnurl_pool.start(_ProfileLnurlRunnable(lnurls, gen, self._lnurl_sigs)) + live_lnurl = f"

    Fetching LNURL-pay metadata…

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

    Lightning (NIP-57)

    " + f"
    {pay_static}{live_lnurl}
    " + ) + + disp = html.escape(display_name_from_profile_or_hex(parsed, pk)) + av = avatar_img_or_placeholder(parsed, 72, border_hex=BORDER) + nip05 = html.escape((parsed.get("nip05") or "").strip()) if parsed.get("nip05") else "" + nip05_html = ( + f"
    {nip05}
    " if nip05 else "" + ) + banner = parsed.get("banner") + banner_html = "" + if banner and str(banner).strip().startswith("https://"): + bu = html.escape(str(banner).strip(), quote=True) + banner_html = ( + f"
    " + f'
    ' + ) + + about_raw = (parsed.get("about") or "").strip() + about_md = "" + if about_raw: + frag = markdown_html_fragment( + about_raw, + db=self._db, + nip30_tags=tags0 or None, + nip30_author_pubkey=pk, + ) + about_md = f"

    About

    {frag}
    " + + raw_json = "" + try: + obj = json.loads(content or "") + if isinstance(obj, dict): + raw_json = json.dumps(obj, indent=2, ensure_ascii=False) + 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}
    " + ) + + k10002 = self._db.get_latest_kind10002_event(pk) + relay_html = ( + 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 '—'}
    " + ) + + follows = self._db.get_latest_kind3_contact_pubkeys(pk, limit=400) + follow_lines: list[str] = [] + for fp in follows[:80]: + href = f"imwald://pub/{fp}" + np = encode_npub(fp) + follow_lines.append( + f'
    {html.escape(np)}' + 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}
    " + ) + + nip30 = self._db.get_author_nip30_emoji_urls(pk) + em_lines: list[str] = [] + for short, url in sorted(nip30.items(), key=lambda x: x[0])[:48]: + em_lines.append( + f"
    :{html.escape(short)}: " + 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}
    " + ) + + notes = self._db.list_events_by_pubkey(pk, kinds=_PROFILE_NOTE_KINDS, limit=40) + note_lines: list[str] = [] + for ev in notes: + eid = str(ev["id"]) + href = f"imwald://note/{eid}" + 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, + db=self._db, + nip30_tags=nip, + nip30_author_pubkey=str(ev.get("pubkey") or "") or None, + ) + note_lines.append( + f'
    ' + f"k{int(ev['kind'])} · {int(ev['created_at'])}
    " + f'' + f"Open in feed" + f"
    {html.escape(snip)}
    " + "
    " + ) + _no_notes = f"No matching notes stored yet." + notes_block = ( + f"

    Recent notes (local DB)

    " + f"{''.join(note_lines) or _no_notes}" + ) + + k0_meta = "" + if k0_ev: + k0_meta = ( + f"

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

    " + ) + + 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}" + "" + ) + self._body.setHtml(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: + s = url.toString() + if url.scheme() == "imwald" and url.host() == "pub": + tail = (url.path() or "").strip("/").lower() + if len(tail) == 64 and all(c in "0123456789abcdef" for c in tail): + self.open_profile.emit(tail) + return + 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()) + return + if s.startswith("https://") or s.startswith("http://"): + QDesktopServices.openUrl(url) diff --git a/tests/test_profile_lnurl.py b/tests/test_profile_lnurl.py new file mode 100644 index 0000000..854a840 --- /dev/null +++ b/tests/test_profile_lnurl.py @@ -0,0 +1,73 @@ +"""Tests for kind-0 lud06/lud16 URL collection and LNURL-pay merge (mocked HTTP).""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +from imwald.core.profile_lnurl import ( + build_merged_lnurl_pay_section, + collect_unique_lnurlp_urls, + lnurlp_url_from_lud16, + normalize_callback, +) + + +def test_lnurlp_url_from_lightning_address() -> None: + assert lnurlp_url_from_lud16("Alice@Example.COM") == "https://example.com/.well-known/lnurlp/Alice" + + +def test_collect_unique_order_and_dedupe() -> None: + u = collect_unique_lnurlp_urls("https://domain/.well-known/lnurlp/x", "https://domain/.well-known/lnurlp/x") + assert u == ["https://domain/.well-known/lnurlp/x"] + u2 = collect_unique_lnurlp_urls(None, "a@b.co") + assert u2 == ["https://b.co/.well-known/lnurlp/a"] + + +def test_normalize_callback_strips_query() -> None: + assert normalize_callback("HTTPS://Host/path?x=1") == "https://host/path" + + +@patch("imwald.core.profile_lnurl.fetch_lnurlp_pay_json") +def test_build_merge_dedupes_same_wallet_callback(mock_fetch: MagicMock) -> None: + def side_effect(url: str) -> dict[str, Any]: + _ = url + return { + "tag": "payRequest", + "callback": "https://wallet.example/lnurlpay/cb?ok=1", + "minSendable": 1000, + "maxSendable": 500_000, + "allowsNostr": True, + "nostrPubkey": "ab" * 32, + "metadata": "[]", + } + + mock_fetch.side_effect = side_effect + html = build_merged_lnurl_pay_section( + [ + "https://relay-a/.well-known/lnurlp/alice", + "https://relay-b/.well-known/lnurlp/alice", + ] + ) + assert mock_fetch.call_count == 2 + assert html.count("wallet.example/lnurlpay/cb") >= 1 + assert "Merged sources" in html + assert "(2)" in html + + +@patch("imwald.core.profile_lnurl.fetch_lnurlp_pay_json") +def test_build_merge_two_distinct_callbacks(mock_fetch: MagicMock) -> None: + urls = ["https://a/1", "https://b/2"] + payloads: list[dict[str, Any]] = [ + {"tag": "payRequest", "callback": "https://w/a", "minSendable": 1000, "maxSendable": 100_000, "metadata": "[]"}, + {"tag": "payRequest", "callback": "https://w/b", "minSendable": 2000, "maxSendable": 200_000, "metadata": "[]"}, + ] + + def side_effect(url: str) -> dict[str, Any]: + return payloads[urls.index(url)] + + mock_fetch.side_effect = side_effect + html = build_merged_lnurl_pay_section(urls) + assert mock_fetch.call_count == 2 + assert html.count("Merged sources") == 2 + assert "https://w/a" in html and "https://w/b" in html