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''
+ )
+ _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"'
+ )
+ _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