Browse Source

profile page

master
Silberengel 2 weeks ago
parent
commit
c26a3768d2
  1. 19
      src/imwald/core/author_html.py
  2. 110
      src/imwald/core/database.py
  3. 4
      src/imwald/core/kind0_profile.py
  4. 226
      src/imwald/core/profile_lnurl.py
  5. 25
      src/imwald/ui/feed_page.py
  6. 60
      src/imwald/ui/main_window.py
  7. 300
      src/imwald/ui/profile_page.py
  8. 73
      tests/test_profile_lnurl.py

19
src/imwald/core/author_html.py

@ -43,17 +43,20 @@ def feed_op_author_block_html( @@ -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'<div style="display:flex;align-items:flex-start;margin-bottom:12px">'
f"{av}"
f'<div style="flex:1;min-width:0">'
@ -62,6 +65,10 @@ def feed_op_author_block_html( @@ -62,6 +65,10 @@ def feed_op_author_block_html(
f"{nip_line_html}{about_line_html}"
f"</div></div>"
)
return (
f'<a href="{href}" style="text-decoration:none;color:inherit;cursor:pointer;display:block" '
f'title="View profile">{inner}</a>'
)
def thread_reply_author_row_html(
@ -79,7 +86,9 @@ 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 = (
'<div style="display:flex;align-items:center;gap:10px;margin:0 0 6px 0">'
f"{av}"
'<div style="flex:1;min-width:0;line-height:1.35">'
@ -88,6 +97,10 @@ def thread_reply_author_row_html( @@ -88,6 +97,10 @@ def thread_reply_author_row_html(
f'<span style="color:{muted};font-size:13px">{npub_e}</span>'
"</div></div>"
)
return (
f'<a href="{href}" style="text-decoration:none;color:inherit;cursor:pointer;display:block" '
f'title="View profile">{inner}</a>'
)
def inline_profile_badge_html(parsed: dict[str, str | None], pubkey_hex: str, npub_tooltip: str, badge_style: str) -> str:

110
src/imwald/core/database.py

@ -400,6 +400,116 @@ class Database: @@ -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=?",

4
src/imwald/core/kind0_profile.py

@ -15,6 +15,8 @@ def parse_kind0_profile(content: str) -> dict[str, str | None]: @@ -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]: @@ -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

226
src/imwald/core/profile_lnurl.py

@ -0,0 +1,226 @@ @@ -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"<p style='margin:4px 0'>{html.escape(meta[:500])}</p>"
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"<p style='margin:4px 0'><b>{html.escape(key)}</b>: {html.escape(v[:800])}</p>"
)
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"<p><i>Unexpected tag {html.escape(str(doc.get('tag')))}</i></p>"
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"<p style='margin:4px 0'><b>Resolved from</b> <code>{html.escape(source_url[:96])}</code></p>",
f"<p style='margin:4px 0'><b>Amount range</b> ({doc.get('minSendable')}{doc.get('maxSendable')} msat): {html.escape(ms)}</p>",
f"<p style='margin:4px 0'><b>allowsNostr</b>: {html.escape(str(allows))} &nbsp; "
f"<b>nostrPubkey</b>: <code>{html.escape(str(npk)[:80])}</code></p>",
f"<p style='margin:4px 0'><b>callback</b> <code>{html.escape(cb[:120])}</code>…</p>",
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"<li>Fetch failed or invalid JSON: <code>{html.escape(u[:80])}</code></li>")
continue
if j.get("tag") != "payRequest":
errors.append(f"<li>Not payRequest: <code>{html.escape(u[:80])}</code></li>")
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"<div style='margin:12px 0;padding:10px;border:1px solid #2a3d34;border-radius:8px'>"
f"<p style='margin:0 0 8px;color:#8fb0a3;font-size:14px'><b>Merged sources</b> ({len(group)}): {src_label}</p>"
f"{format_lnurl_pay_html(sources[0], agg)}"
f"</div>"
)
err_html = f"<ul style='color:#6a8578'>{''.join(errors)}</ul>" if errors else ""
if not blocks and not errors:
return ""
head = "<h3 style='color:#dceee6;margin:16px 0 8px'>Lightning (LNURL-pay, live)</h3>"
return head + "".join(blocks) + err_html

25
src/imwald/ui/feed_page.py

@ -8,8 +8,8 @@ import re @@ -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( @@ -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): @@ -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): @@ -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): @@ -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): @@ -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)

60
src/imwald/ui/main_window.py

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

300
src/imwald/ui/profile_page.py

@ -0,0 +1,300 @@ @@ -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"<p style='margin:6px 0;color:{TEXT_DIM}'><b>lud06</b> (LNURL / NIP-57): "
f"<code style='color:{TEXT_MUTED}'>{html.escape(lud06_s[:200])}</code></p>"
)
if lud16_s:
pay_rows.append(
f"<p style='margin:6px 0;color:{TEXT_DIM}'><b>lud16</b> (Lightning address or HTTPS LNURL): "
f"<code style='color:{TEXT_MUTED}'>{html.escape(lud16_s[:200])}</code></p>"
)
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"<p style='color:{TEXT_DIM}'><i>Fetching LNURL-pay metadata…</i></p>"
pay_block = ""
if pay_static or live_lnurl:
pay_block = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Lightning (NIP-57)</h3>"
f"<div style='margin-bottom:8px'>{pay_static}{live_lnurl}</div>"
)
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"<div style='color:{TEXT_MUTED};font-size:16px;margin-top:6px'>{nip05}</div>" 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"<div style='margin-bottom:12px;border-radius:10px;overflow:hidden'>"
f'<img src="{bu}" alt="" style="width:100%;max-height:160px;object-fit:cover" /></div>'
)
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"<h3 style='color:{TEXT};margin:16px 0 8px'>About</h3><div class=\"md\">{frag}</div>"
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"<h3 style='color:{TEXT};margin:16px 0 8px'>Kind 0 JSON (full)</h3>"
f"<pre style='color:{TEXT_DIM};font-size:14px;white-space:pre-wrap;word-break:break-all;"
f"background:rgba(0,0,0,0.25);padding:12px;border-radius:8px;border:1px solid {BORDER}'>{raw_esc}</pre>"
)
k10002 = self._db.get_latest_kind10002_event(pk)
relay_html = (
f"<p style='color:{TEXT_DIM};font-size:15px'>"
f"<i>No NIP-65 relay list (kind 10002) in local DB yet.</i></p>"
)
if k10002:
reads, writes = parse_kind10002_tags(k10002.get("tags") or [])
r_esc = "<br>".join(html.escape(u) for u in reads[:40])
w_esc = "<br>".join(html.escape(u) for u in writes[:40])
relay_html = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Relays (NIP-65, kind 10002)</h3>"
f"<p style='color:{TEXT_MUTED};font-size:15px'><b>Read</b></p>"
f"<div style='color:{TEXT_DIM};font-size:14px'>{r_esc or ''}</div>"
f"<p style='color:{TEXT_MUTED};font-size:15px;margin-top:10px'><b>Write</b></p>"
f"<div style='color:{TEXT_DIM};font-size:14px'>{w_esc or ''}</div>"
)
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'<div style="margin:4px 0"><a href="{html.escape(href, quote=True)}" '
f'style="color:{TEXT};text-decoration:none">{html.escape(np)}</a>'
f'<span style="color:{TEXT_DIM};font-size:13px"> · {html.escape(fp[:16])}…</span></div>'
)
_no_follow = f"<i style='color:{TEXT_DIM}'>No kind 3 in local DB.</i>"
follow_block = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Following (kind 3, local snapshot)</h3>"
f"<div style='font-size:14px'>{''.join(follow_lines) or _no_follow}</div>"
)
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"<div style='margin:3px 0'><code style='color:{TEXT_MUTED}'>:{html.escape(short)}:</code> "
f'<a href="{html.escape(url, quote=True)}" style="color:{TEXT}">{html.escape(url[:48])}…</a></div>'
)
_no_emoji = f"<i style='color:{TEXT_DIM}'>No emoji packs indexed yet.</i>"
emoji_block = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Custom emoji (NIP-30, local)</h3>"
f"<div>{''.join(em_lines) or _no_emoji}</div>"
)
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'<div style="margin:8px 0;padding:8px;border:1px solid {BORDER};border-radius:8px">'
f"<span style='color:{TEXT_MUTED};font-size:13px'>k{int(ev['kind'])} · {int(ev['created_at'])}</span><br>"
f'<a href="{html.escape(href, quote=True)}" style="color:{TEXT};font-weight:600;text-decoration:none">'
f"Open in feed</a>"
f"<div style='color:{TEXT_DIM};margin-top:6px;font-size:15px'>{html.escape(snip)}</div>"
"</div>"
)
_no_notes = f"<i style='color:{TEXT_DIM}'>No matching notes stored yet.</i>"
notes_block = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Recent notes (local DB)</h3>"
f"{''.join(note_lines) or _no_notes}"
)
k0_meta = ""
if k0_ev:
k0_meta = (
f"<p style='color:{TEXT_DIM};font-size:14px'>Kind 0 event id: <code>{html.escape(str(k0_ev['id']))}</code>"
f" · updated {created0}</p>"
)
doc = (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body style='padding:12px 14px'>"
f"{banner_html}"
f"<div style='display:flex;align-items:flex-start;gap:14px;margin-bottom:8px'>"
f"{av}"
f"<div style='flex:1;min-width:0'>"
f"<div style='font-size:26px;font-weight:700;color:{TEXT}'>{disp}</div>"
f"<div style='color:{TEXT_MUTED};font-size:15px;margin-top:4px'>{html.escape(npub)}</div>"
f"<div style='color:{TEXT_DIM};font-size:14px;margin-top:2px'>{html.escape(pk[:24])}…</div>"
f"{nip05_html}"
f"</div></div>"
f"{k0_meta}"
f"{about_md}"
f"{pay_block}"
f"{relay_html}"
f"{follow_block}"
f"{emoji_block}"
f"{json_block}"
f"{notes_block}"
"</body></html>"
)
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)

73
tests/test_profile_lnurl.py

@ -0,0 +1,73 @@ @@ -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
Loading…
Cancel
Save