|
|
|
|
@ -4,27 +4,279 @@ from __future__ import annotations
@@ -4,27 +4,279 @@ from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import html |
|
|
|
|
import json |
|
|
|
|
from datetime import datetime, timezone |
|
|
|
|
from typing import cast |
|
|
|
|
|
|
|
|
|
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, Signal, QUrl |
|
|
|
|
from PySide6.QtGui import QDesktopServices |
|
|
|
|
from PySide6.QtWidgets import QFrame, QScrollArea, QTabWidget, QVBoxLayout, QWidget |
|
|
|
|
from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal, QUrl |
|
|
|
|
from PySide6.QtGui import QDesktopServices, QMouseEvent, QPainter, QPainterPath, QPixmap, QResizeEvent |
|
|
|
|
from PySide6.QtWidgets import QFrame, QLabel, QSizePolicy, QSplitter, QTabWidget, QVBoxLayout, QWidget |
|
|
|
|
|
|
|
|
|
from imwald.core.author_html import avatar_img_or_placeholder |
|
|
|
|
from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX |
|
|
|
|
from imwald.core.database import Database |
|
|
|
|
from imwald.core.forest_avatar import build_forest_avatar_png, build_forest_banner_png |
|
|
|
|
from imwald.core.kind0_profile import display_name_from_profile_or_hex, parse_kind0_profile |
|
|
|
|
from imwald.core.profile_lnurl import build_merged_lnurl_pay_section, collect_unique_lnurlp_urls |
|
|
|
|
from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary |
|
|
|
|
from imwald.core.nip19 import encode_npub |
|
|
|
|
from imwald.core.nostr_engine import NostrEngine |
|
|
|
|
from imwald.core.relay_list import parse_kind10002_tags |
|
|
|
|
from imwald.ui.media_viewer_dialog import try_open_media_url_in_app |
|
|
|
|
from imwald.ui.note_text_browser import NoteTextBrowser |
|
|
|
|
from imwald.ui.theme import BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED |
|
|
|
|
from imwald.ui.theme import ACCENT_SOFT, BG_CARD, BG_CODE, BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED |
|
|
|
|
|
|
|
|
|
# Notes to list under “Recent in local DB” (feed-shaped kinds). |
|
|
|
|
_PROFILE_NOTE_KINDS: tuple[int, ...] = (1, 6, 20, 21, 30023, 9802, 11) |
|
|
|
|
|
|
|
|
|
PROFILE_HERO_BANNER_H = 240 |
|
|
|
|
PROFILE_HERO_AVATAR = 136 |
|
|
|
|
PROFILE_HERO_OVERLAP = 0.48 # fraction of avatar height drawn above the banner bottom edge |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cover_pixmap(src: QPixmap, tw: int, th: int) -> QPixmap: |
|
|
|
|
if src.isNull() or tw < 2 or th < 2: |
|
|
|
|
return QPixmap() |
|
|
|
|
sc = src.scaled( |
|
|
|
|
tw, |
|
|
|
|
th, |
|
|
|
|
Qt.AspectRatioMode.KeepAspectRatioByExpanding, |
|
|
|
|
Qt.TransformationMode.SmoothTransformation, |
|
|
|
|
) |
|
|
|
|
sw, sh = sc.width(), sc.height() |
|
|
|
|
x = max(0, (sw - tw) // 2) |
|
|
|
|
y = max(0, (sh - th) // 2) |
|
|
|
|
return sc.copy(x, y, tw, th) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _square_center_crop(pm: QPixmap) -> QPixmap: |
|
|
|
|
if pm.isNull(): |
|
|
|
|
return QPixmap() |
|
|
|
|
s = min(pm.width(), pm.height()) |
|
|
|
|
if s < 2: |
|
|
|
|
return pm |
|
|
|
|
x = (pm.width() - s) // 2 |
|
|
|
|
y = (pm.height() - s) // 2 |
|
|
|
|
return pm.copy(x, y, s, s) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _circle_pixmap(src: QPixmap, diameter: int) -> QPixmap: |
|
|
|
|
if src.isNull() or diameter < 4: |
|
|
|
|
return QPixmap() |
|
|
|
|
scaled = src.scaled( |
|
|
|
|
diameter, |
|
|
|
|
diameter, |
|
|
|
|
Qt.AspectRatioMode.KeepAspectRatioByExpanding, |
|
|
|
|
Qt.TransformationMode.SmoothTransformation, |
|
|
|
|
) |
|
|
|
|
sw, sh = scaled.width(), scaled.height() |
|
|
|
|
x = max(0, (sw - diameter) // 2) |
|
|
|
|
y = max(0, (sh - diameter) // 2) |
|
|
|
|
inner = scaled.copy(x, y, diameter, diameter) |
|
|
|
|
out = QPixmap(diameter, diameter) |
|
|
|
|
out.fill(Qt.GlobalColor.transparent) |
|
|
|
|
painter = QPainter(out) |
|
|
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing) |
|
|
|
|
path = QPainterPath() |
|
|
|
|
path.addEllipse(0, 0, diameter, diameter) |
|
|
|
|
painter.setClipPath(path) |
|
|
|
|
painter.drawPixmap(0, 0, inner) |
|
|
|
|
painter.end() |
|
|
|
|
return out |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _BannerLabel(QLabel): |
|
|
|
|
"""Opens the remote banner URL in the system browser when set.""" |
|
|
|
|
|
|
|
|
|
def __init__(self, parent: QWidget | None = None) -> None: |
|
|
|
|
super().__init__(parent) |
|
|
|
|
self._open_url: str | None = None |
|
|
|
|
self.setCursor(Qt.CursorShape.PointingHandCursor) |
|
|
|
|
|
|
|
|
|
def set_open_url(self, url: str | None) -> None: |
|
|
|
|
self._open_url = url.strip() if url else None |
|
|
|
|
self.setCursor( |
|
|
|
|
Qt.CursorShape.PointingHandCursor if self._open_url else Qt.CursorShape.ArrowCursor |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
def mousePressEvent(self, event: QMouseEvent) -> None: |
|
|
|
|
if self._open_url and event.button() == Qt.MouseButton.LeftButton: |
|
|
|
|
q = QUrl(self._open_url) |
|
|
|
|
if not try_open_media_url_in_app(self.window(), q): |
|
|
|
|
QDesktopServices.openUrl(q) |
|
|
|
|
super().mousePressEvent(event) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProfileHeroFrame(QFrame): |
|
|
|
|
"""Full-width cover + circular avatar overlapping the lower banner (social-style header).""" |
|
|
|
|
|
|
|
|
|
BANNER_H = PROFILE_HERO_BANNER_H |
|
|
|
|
AVATAR = PROFILE_HERO_AVATAR |
|
|
|
|
|
|
|
|
|
def __init__(self, parent: QWidget | None = None) -> None: |
|
|
|
|
super().__init__(parent) |
|
|
|
|
self.setObjectName("ProfileHeroFrame") |
|
|
|
|
self._banner_src = QPixmap() |
|
|
|
|
self._disp = QLabel(self) |
|
|
|
|
self._npub = QLabel(self) |
|
|
|
|
self._pk = QLabel(self) |
|
|
|
|
self._nip05 = QLabel(self) |
|
|
|
|
self._meta = QLabel(self) |
|
|
|
|
self._banner_lbl = _BannerLabel(self) |
|
|
|
|
self._banner_lbl.setScaledContents(False) |
|
|
|
|
self._avatar_lbl = QLabel(self) |
|
|
|
|
self._avatar_lbl.setFixedSize(self.AVATAR, self.AVATAR) |
|
|
|
|
self._avatar_lbl.setScaledContents(False) |
|
|
|
|
self._avatar_lbl.setStyleSheet( |
|
|
|
|
f"QLabel {{ border: 3px solid {BORDER}; border-radius: {self.AVATAR // 2}px; " |
|
|
|
|
f"background-color: {BG_CODE}; }}" |
|
|
|
|
) |
|
|
|
|
for lb in (self._disp, self._npub, self._pk, self._nip05, self._meta): |
|
|
|
|
lb.setWordWrap(True) |
|
|
|
|
lb.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) |
|
|
|
|
self._disp.setStyleSheet(f"color:{TEXT}; font-size:26px; font-weight:700;") |
|
|
|
|
self._npub.setStyleSheet(f"color:{TEXT_MUTED}; font-size:14px;") |
|
|
|
|
self._pk.setStyleSheet(f"color:{TEXT_DIM}; font-size:12px;") |
|
|
|
|
self._nip05.setStyleSheet(f"color:{TEXT_MUTED}; font-size:15px;") |
|
|
|
|
self._meta.setStyleSheet(f"color:{TEXT_DIM}; font-size:12px;") |
|
|
|
|
self._relayout() |
|
|
|
|
|
|
|
|
|
def set_banner_source(self, pm: QPixmap) -> None: |
|
|
|
|
self._banner_src = pm if not pm.isNull() else QPixmap() |
|
|
|
|
self._relayout() |
|
|
|
|
|
|
|
|
|
def set_banner_open_url(self, url: str | None) -> None: |
|
|
|
|
self._banner_lbl.set_open_url(url) |
|
|
|
|
|
|
|
|
|
def set_avatar_pixmap(self, pm: QPixmap) -> None: |
|
|
|
|
if pm.isNull(): |
|
|
|
|
self._avatar_lbl.clear() |
|
|
|
|
else: |
|
|
|
|
self._avatar_lbl.setPixmap(pm) |
|
|
|
|
|
|
|
|
|
def set_identity( |
|
|
|
|
self, |
|
|
|
|
display: str, |
|
|
|
|
npub: str, |
|
|
|
|
pubkey_hex: str, |
|
|
|
|
nip05: str | None, |
|
|
|
|
k0_meta: str | None, |
|
|
|
|
) -> None: |
|
|
|
|
self._disp.setText(display) |
|
|
|
|
self._npub.setText(npub) |
|
|
|
|
self._pk.setText(pubkey_hex) |
|
|
|
|
self._nip05.setText(nip05 or "") |
|
|
|
|
self._nip05.setVisible(bool(nip05 and nip05.strip())) |
|
|
|
|
self._meta.setText(k0_meta or "") |
|
|
|
|
self._meta.setVisible(bool(k0_meta and k0_meta.strip())) |
|
|
|
|
self._relayout() |
|
|
|
|
|
|
|
|
|
def minimumSizeHint(self) -> QSize: # noqa: N802 |
|
|
|
|
overlap = int(self.AVATAR * PROFILE_HERO_OVERLAP) |
|
|
|
|
h = self.BANNER_H + self.AVATAR - overlap + 18 |
|
|
|
|
return QSize(280, h) |
|
|
|
|
|
|
|
|
|
def resizeEvent(self, event: QResizeEvent) -> None: |
|
|
|
|
self._relayout() |
|
|
|
|
super().resizeEvent(event) |
|
|
|
|
|
|
|
|
|
def _relayout(self) -> None: |
|
|
|
|
w = max(self.width(), 80) |
|
|
|
|
bh = self.BANNER_H |
|
|
|
|
av = self.AVATAR |
|
|
|
|
cover = _cover_pixmap(self._banner_src, w, bh) |
|
|
|
|
self._banner_lbl.setPixmap(cover) |
|
|
|
|
self._banner_lbl.setGeometry(0, 0, w, bh) |
|
|
|
|
ax = 22 |
|
|
|
|
ay = bh - int(av * PROFILE_HERO_OVERLAP) |
|
|
|
|
self._avatar_lbl.setGeometry(ax, ay, av, av) |
|
|
|
|
tx = ax + av + 16 |
|
|
|
|
tw = w - tx - 16 |
|
|
|
|
y0 = ay + 4 |
|
|
|
|
self._disp.setGeometry(tx, y0, tw, 34) |
|
|
|
|
self._npub.setGeometry(tx, y0 + 36, tw, 44) |
|
|
|
|
self._pk.setGeometry(tx, y0 + 82, tw, 36) |
|
|
|
|
y1 = y0 + 118 |
|
|
|
|
if self._nip05.isVisible(): |
|
|
|
|
self._nip05.setGeometry(tx, y1, tw, 28) |
|
|
|
|
y1 += 30 |
|
|
|
|
if self._meta.isVisible(): |
|
|
|
|
self._meta.setGeometry(tx, y1, tw, 40) |
|
|
|
|
overlap = int(av * PROFILE_HERO_OVERLAP) |
|
|
|
|
total_h = bh + av - overlap + 14 |
|
|
|
|
self.setFixedHeight(max(total_h, self.minimumSizeHint().height())) |
|
|
|
|
self._avatar_lbl.raise_() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _CoverImageSignals(QObject): |
|
|
|
|
banner_ready = Signal(object, int) |
|
|
|
|
avatar_ready = Signal(object, int) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _ProfileHttpImageRunnable(QRunnable): |
|
|
|
|
def __init__(self, url: str, gen: int, mode: str, out: _CoverImageSignals) -> None: |
|
|
|
|
super().__init__() |
|
|
|
|
self._url = url |
|
|
|
|
self._gen = gen |
|
|
|
|
self._mode = mode |
|
|
|
|
self._out = out |
|
|
|
|
|
|
|
|
|
def run(self) -> None: |
|
|
|
|
empty = QPixmap() |
|
|
|
|
pm = QPixmap() |
|
|
|
|
try: |
|
|
|
|
from urllib.request import Request, urlopen |
|
|
|
|
|
|
|
|
|
req = Request(self._url, headers={"User-Agent": "imwald/1"}, method="GET") |
|
|
|
|
with urlopen(req, timeout=16) as resp: # noqa: S310 |
|
|
|
|
data = resp.read(6 * 1024 * 1024 + 1) |
|
|
|
|
if len(data) > 6 * 1024 * 1024 or not pm.loadFromData(data): |
|
|
|
|
self._emit_fail() |
|
|
|
|
return |
|
|
|
|
except OSError: |
|
|
|
|
self._emit_fail() |
|
|
|
|
return |
|
|
|
|
if self._mode == "banner": |
|
|
|
|
self._out.banner_ready.emit(pm, self._gen) |
|
|
|
|
elif self._mode == "avatar": |
|
|
|
|
sq = _square_center_crop(pm) |
|
|
|
|
if sq.isNull(): |
|
|
|
|
self._out.avatar_ready.emit(empty, self._gen) |
|
|
|
|
return |
|
|
|
|
scaled = sq.scaled( |
|
|
|
|
400, |
|
|
|
|
400, |
|
|
|
|
Qt.AspectRatioMode.IgnoreAspectRatio, |
|
|
|
|
Qt.TransformationMode.SmoothTransformation, |
|
|
|
|
) |
|
|
|
|
circ = _circle_pixmap(scaled, ProfileHeroFrame.AVATAR) |
|
|
|
|
self._out.avatar_ready.emit(circ, self._gen) |
|
|
|
|
else: |
|
|
|
|
self._emit_fail() |
|
|
|
|
|
|
|
|
|
def _emit_fail(self) -> None: |
|
|
|
|
if self._mode == "banner": |
|
|
|
|
self._out.banner_ready.emit(QPixmap(), self._gen) |
|
|
|
|
else: |
|
|
|
|
self._out.avatar_ready.emit(QPixmap(), self._gen) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sec_title(label: str) -> str: |
|
|
|
|
return ( |
|
|
|
|
f'<div style="font-size:11px;font-weight:700;color:{TEXT_MUTED};text-transform:uppercase;' |
|
|
|
|
f'letter-spacing:0.07em;margin:0 0 12px 0">{html.escape(label)}</div>' |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fmt_event_time(created_at: int | float | str | None) -> str: |
|
|
|
|
if created_at is None: |
|
|
|
|
return "—" |
|
|
|
|
try: |
|
|
|
|
ts = int(created_at) |
|
|
|
|
except (TypeError, ValueError): |
|
|
|
|
return str(created_at) |
|
|
|
|
try: |
|
|
|
|
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d · %H:%M UTC") |
|
|
|
|
except (OSError, OverflowError, ValueError): |
|
|
|
|
return str(ts) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _ProfileLnurlSignals(QObject): |
|
|
|
|
finished = Signal(str, int) |
|
|
|
|
@ -45,7 +297,7 @@ class _ProfileLnurlRunnable(QRunnable):
@@ -45,7 +297,7 @@ class _ProfileLnurlRunnable(QRunnable):
|
|
|
|
|
class ProfilePage(QWidget): |
|
|
|
|
"""One pubkey: metadata, NIP-65 relays, follows (kind 3), emoji inventory, raw JSON, recent notes.""" |
|
|
|
|
|
|
|
|
|
open_note = Signal(str) |
|
|
|
|
open_note_new_tab = Signal(str) |
|
|
|
|
open_profile = Signal(str) |
|
|
|
|
|
|
|
|
|
def __init__( |
|
|
|
|
@ -60,25 +312,50 @@ class ProfilePage(QWidget):
@@ -60,25 +312,50 @@ class ProfilePage(QWidget):
|
|
|
|
|
self._db = db |
|
|
|
|
self._engine = engine |
|
|
|
|
self._pubkey = pubkey_hex.strip().lower() |
|
|
|
|
scroll = QScrollArea(self) |
|
|
|
|
scroll.setWidgetResizable(True) |
|
|
|
|
scroll.setFrameShape(QFrame.Shape.NoFrame) |
|
|
|
|
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) |
|
|
|
|
self._body = NoteTextBrowser() |
|
|
|
|
self._body.setObjectName("ProfileBody") |
|
|
|
|
self._body.setOpenLinks(False) |
|
|
|
|
self._body.setOpenExternalLinks(False) |
|
|
|
|
self._body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) |
|
|
|
|
self._body.anchorClicked.connect(self._on_anchor) |
|
|
|
|
scroll.setWidget(self._body) |
|
|
|
|
self._hero = ProfileHeroFrame(self) |
|
|
|
|
self._left_body = NoteTextBrowser() |
|
|
|
|
self._left_body.setObjectName("ProfileBodyLeft") |
|
|
|
|
self._left_body.setOpenLinks(False) |
|
|
|
|
self._left_body.setOpenExternalLinks(False) |
|
|
|
|
self._left_body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) |
|
|
|
|
self._left_body.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) |
|
|
|
|
self._left_body.anchorClicked.connect(self._dispatch_profile_anchor) |
|
|
|
|
self._feed_body = NoteTextBrowser() |
|
|
|
|
self._feed_body.setObjectName("ProfileBodyFeed") |
|
|
|
|
self._feed_body.setOpenLinks(False) |
|
|
|
|
self._feed_body.setOpenExternalLinks(False) |
|
|
|
|
self._feed_body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) |
|
|
|
|
self._feed_body.anchorClicked.connect(self._dispatch_profile_anchor) |
|
|
|
|
self._left_column = QWidget(self) |
|
|
|
|
left_lay = QVBoxLayout(self._left_column) |
|
|
|
|
left_lay.setContentsMargins(0, 0, 0, 0) |
|
|
|
|
left_lay.setSpacing(0) |
|
|
|
|
left_lay.addWidget(self._hero, 0) |
|
|
|
|
left_lay.addWidget(self._left_body, 1) |
|
|
|
|
split = QSplitter(Qt.Orientation.Horizontal, self) |
|
|
|
|
split.setObjectName("ProfileSplit") |
|
|
|
|
split.setChildrenCollapsible(False) |
|
|
|
|
split.addWidget(self._left_column) |
|
|
|
|
split.addWidget(self._feed_body) |
|
|
|
|
split.setStretchFactor(0, 100) |
|
|
|
|
split.setStretchFactor(1, 58) |
|
|
|
|
split.setSizes([720, 400]) |
|
|
|
|
self._feed_body.setMinimumWidth(260) |
|
|
|
|
lay = QVBoxLayout(self) |
|
|
|
|
lay.setContentsMargins(0, 0, 0, 0) |
|
|
|
|
lay.addWidget(scroll) |
|
|
|
|
lay.addWidget(split) |
|
|
|
|
self._lnurl_gen = 0 |
|
|
|
|
self._lnurl_sigs = _ProfileLnurlSignals(self) |
|
|
|
|
self._lnurl_sigs.finished.connect(self._on_lnurl_profile_ready) |
|
|
|
|
self._lnurl_pool = QThreadPool(self) |
|
|
|
|
self._lnurl_pool.setMaxThreadCount(1) |
|
|
|
|
self._cover_banner_gen = 0 |
|
|
|
|
self._cover_avatar_gen = 0 |
|
|
|
|
self._cov_sigs = _CoverImageSignals(self) |
|
|
|
|
self._cov_sigs.banner_ready.connect(self._on_cover_banner_http) |
|
|
|
|
self._cov_sigs.avatar_ready.connect(self._on_cover_avatar_http) |
|
|
|
|
self._cov_pool = QThreadPool(self) |
|
|
|
|
self._cov_pool.setMaxThreadCount(3) |
|
|
|
|
self.refresh() |
|
|
|
|
|
|
|
|
|
def tab_title(self) -> str: |
|
|
|
|
@ -92,6 +369,18 @@ class ProfilePage(QWidget):
@@ -92,6 +369,18 @@ class ProfilePage(QWidget):
|
|
|
|
|
return |
|
|
|
|
self.refresh(from_lnurl=True, lnurl_html=html) |
|
|
|
|
|
|
|
|
|
def _on_cover_banner_http(self, pm: object, gen: int) -> None: |
|
|
|
|
if gen != self._cover_banner_gen: |
|
|
|
|
return |
|
|
|
|
if isinstance(pm, QPixmap) and not pm.isNull(): |
|
|
|
|
self._hero.set_banner_source(pm) |
|
|
|
|
|
|
|
|
|
def _on_cover_avatar_http(self, pm: object, gen: int) -> None: |
|
|
|
|
if gen != self._cover_avatar_gen: |
|
|
|
|
return |
|
|
|
|
if isinstance(pm, QPixmap) and not pm.isNull(): |
|
|
|
|
self._hero.set_avatar_pixmap(pm) |
|
|
|
|
|
|
|
|
|
def refresh(self, *, from_lnurl: bool = False, lnurl_html: str | None = None) -> None: |
|
|
|
|
if not from_lnurl: |
|
|
|
|
self._engine.enqueue_author_metadata(self._pubkey) |
|
|
|
|
@ -129,35 +418,57 @@ class ProfilePage(QWidget):
@@ -129,35 +418,57 @@ class ProfilePage(QWidget):
|
|
|
|
|
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 = "" |
|
|
|
|
pay_card = "" |
|
|
|
|
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>" |
|
|
|
|
) |
|
|
|
|
pay_card = f"{_sec_title('Lightning · NIP-57')}<div>{pay_static}{live_lnurl}</div>" |
|
|
|
|
|
|
|
|
|
disp = html.escape(display_name_from_profile_or_hex(parsed, pk)) |
|
|
|
|
prof_href = html.escape(f"imwald://pub/{pk}", quote=True) |
|
|
|
|
av = avatar_img_or_placeholder(parsed, 72, border_hex=BORDER, profile_href=prof_href) |
|
|
|
|
nip05 = html.escape((parsed.get("nip05") or "").strip()) if parsed.get("nip05") else "" |
|
|
|
|
nip05_html = ( |
|
|
|
|
f"<div style='color:{TEXT_MUTED};font-size:16px;margin-top:6px'>{nip05}</div>" if nip05 else "" |
|
|
|
|
disp_plain = display_name_from_profile_or_hex(parsed, pk) |
|
|
|
|
nip05_plain = (parsed.get("nip05") or "").strip() if parsed.get("nip05") else "" |
|
|
|
|
banner_raw = parsed.get("banner") |
|
|
|
|
banner_https = ( |
|
|
|
|
str(banner_raw).strip() |
|
|
|
|
if banner_raw and str(banner_raw).strip().startswith("https://") |
|
|
|
|
else "" |
|
|
|
|
) |
|
|
|
|
picture_raw = parsed.get("picture") |
|
|
|
|
picture_https = ( |
|
|
|
|
str(picture_raw).strip() |
|
|
|
|
if picture_raw and str(picture_raw).strip().startswith("https://") |
|
|
|
|
else "" |
|
|
|
|
) |
|
|
|
|
banner = parsed.get("banner") |
|
|
|
|
banner_html = "" |
|
|
|
|
if banner and str(banner).strip().startswith("https://"): |
|
|
|
|
bu = html.escape(str(banner).strip(), quote=True) |
|
|
|
|
bw = IMAGE_DISPLAY_MAX_WIDTH_PX |
|
|
|
|
banner_html = ( |
|
|
|
|
f"<div style='margin-bottom:12px;border-radius:10px;overflow:hidden;" |
|
|
|
|
f"max-width:{bw}px'>" |
|
|
|
|
f'<a class="imwald-fullimg" href="{bu}" title="View full size" style="text-decoration:none">' |
|
|
|
|
f'<img src="{bu}" alt="" style="max-width:100%;width:100%;max-height:160px;object-fit:cover" />' |
|
|
|
|
f"</a></div>" |
|
|
|
|
forest_seed = int(pk[:16], 16) % (2**31) if len(pk) >= 16 else 0 |
|
|
|
|
ph_b = QPixmap() |
|
|
|
|
bh_bytes = build_forest_banner_png( |
|
|
|
|
width=2000, height=ProfileHeroFrame.BANNER_H + 48, seed=forest_seed |
|
|
|
|
) |
|
|
|
|
if not ph_b.loadFromData(bh_bytes): |
|
|
|
|
ph_b.loadFromData( |
|
|
|
|
build_forest_banner_png(width=2000, height=ProfileHeroFrame.BANNER_H + 48, seed=42) |
|
|
|
|
) |
|
|
|
|
self._hero.set_banner_source(ph_b if not ph_b.isNull() else QPixmap()) |
|
|
|
|
ph_a = QPixmap() |
|
|
|
|
if not ph_a.loadFromData(build_forest_avatar_png(size=384, seed=forest_seed)): |
|
|
|
|
ph_a.loadFromData(build_forest_avatar_png(size=384, seed=42)) |
|
|
|
|
av_circ = _circle_pixmap(ph_a, ProfileHeroFrame.AVATAR) |
|
|
|
|
self._hero.set_avatar_pixmap(av_circ) |
|
|
|
|
self._cover_banner_gen += 1 |
|
|
|
|
b_gen = self._cover_banner_gen |
|
|
|
|
self._cover_avatar_gen += 1 |
|
|
|
|
a_gen = self._cover_avatar_gen |
|
|
|
|
if banner_https: |
|
|
|
|
self._cov_pool.start(_ProfileHttpImageRunnable(banner_https, b_gen, "banner", self._cov_sigs)) |
|
|
|
|
if picture_https: |
|
|
|
|
self._cov_pool.start(_ProfileHttpImageRunnable(picture_https, a_gen, "avatar", self._cov_sigs)) |
|
|
|
|
self._hero.set_banner_open_url(banner_https or None) |
|
|
|
|
k0_meta_plain = "" |
|
|
|
|
if k0_ev: |
|
|
|
|
k0_meta_plain = ( |
|
|
|
|
f"Kind 0 id {str(k0_ev['id'])[:20]}… · profile updated {_fmt_event_time(created0)}" |
|
|
|
|
) |
|
|
|
|
self._hero.set_identity(disp_plain, npub, pk, nip05_plain or None, k0_meta_plain or None) |
|
|
|
|
|
|
|
|
|
about_raw = (parsed.get("about") or "").strip() |
|
|
|
|
about_md = "" |
|
|
|
|
about_inner = "" |
|
|
|
|
if about_raw: |
|
|
|
|
frag = markdown_html_fragment( |
|
|
|
|
about_raw, |
|
|
|
|
@ -165,7 +476,7 @@ class ProfilePage(QWidget):
@@ -165,7 +476,7 @@ class ProfilePage(QWidget):
|
|
|
|
|
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>" |
|
|
|
|
about_inner = f"{_sec_title('About')}<div class=\"md\">{frag}</div>" |
|
|
|
|
|
|
|
|
|
raw_json = "" |
|
|
|
|
try: |
|
|
|
|
@ -175,27 +486,27 @@ class ProfilePage(QWidget):
@@ -175,27 +486,27 @@ class ProfilePage(QWidget):
|
|
|
|
|
except json.JSONDecodeError: |
|
|
|
|
raw_json = content or "" |
|
|
|
|
raw_esc = html.escape(raw_json[:12000] + ("…" if len(raw_json) > 12000 else ""), quote=False) |
|
|
|
|
json_block = ( |
|
|
|
|
f"<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>" |
|
|
|
|
json_inner = ( |
|
|
|
|
f"{_sec_title('Kind 0 JSON')}" |
|
|
|
|
f"<pre style='color:{TEXT_DIM};font-size:13px;white-space:pre-wrap;word-break:break-all;" |
|
|
|
|
f"background:{BG_CODE};padding:12px;border-radius:8px;border:1px solid {BORDER};margin:0'>{raw_esc}</pre>" |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
k10002 = self._db.get_latest_kind10002_event(pk) |
|
|
|
|
relay_html = ( |
|
|
|
|
f"<p style='color:{TEXT_DIM};font-size:15px'>" |
|
|
|
|
relay_inner = ( |
|
|
|
|
f"<p style='color:{TEXT_DIM};font-size:15px;margin:0'>" |
|
|
|
|
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>" |
|
|
|
|
relay_inner = ( |
|
|
|
|
f"{_sec_title('Relays · NIP-65 (kind 10002)')}" |
|
|
|
|
f"<p style='color:{TEXT_MUTED};font-size:14px;margin:0 0 6px 0'><b>Read</b></p>" |
|
|
|
|
f"<div style='color:{TEXT_DIM};font-size:14px;line-height:1.45'>{r_esc or '—'}</div>" |
|
|
|
|
f"<p style='color:{TEXT_MUTED};font-size:14px;margin:12px 0 6px 0'><b>Write</b></p>" |
|
|
|
|
f"<div style='color:{TEXT_DIM};font-size:14px;line-height:1.45'>{w_esc or '—'}</div>" |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
follows = self._db.get_latest_kind3_contact_pubkeys(pk, limit=400) |
|
|
|
|
@ -209,9 +520,9 @@ class ProfilePage(QWidget):
@@ -209,9 +520,9 @@ class ProfilePage(QWidget):
|
|
|
|
|
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>" |
|
|
|
|
follow_inner = ( |
|
|
|
|
f"{_sec_title('Following · kind 3 (local)')}" |
|
|
|
|
f"<div style='font-size:14px;line-height:1.45'>{''.join(follow_lines) or _no_follow}</div>" |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
nip30 = self._db.get_author_nip30_emoji_urls(pk) |
|
|
|
|
@ -222,10 +533,7 @@ class ProfilePage(QWidget):
@@ -222,10 +533,7 @@ class ProfilePage(QWidget):
|
|
|
|
|
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>" |
|
|
|
|
) |
|
|
|
|
emoji_inner = f"{_sec_title('Custom emoji · NIP-30')}" 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] = [] |
|
|
|
|
@ -235,62 +543,73 @@ class ProfilePage(QWidget):
@@ -235,62 +543,73 @@ class ProfilePage(QWidget):
|
|
|
|
|
nip = cast(list[list[str]], ev["tags"]) if isinstance(ev.get("tags"), list) else None |
|
|
|
|
snip = markdown_plain_summary( |
|
|
|
|
ev.get("content") or "", |
|
|
|
|
max_len=72, |
|
|
|
|
max_len=96, |
|
|
|
|
db=self._db, |
|
|
|
|
nip30_tags=nip, |
|
|
|
|
nip30_author_pubkey=str(ev.get("pubkey") or "") or None, |
|
|
|
|
) |
|
|
|
|
t_human = _fmt_event_time(ev.get("created_at")) |
|
|
|
|
kind_lbl = int(ev["kind"]) |
|
|
|
|
esc_href = html.escape(href, quote=True) |
|
|
|
|
note_lines.append( |
|
|
|
|
f'<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>" |
|
|
|
|
f'<a href="{esc_href}" style="display:block;text-decoration:none;color:{TEXT};' |
|
|
|
|
f"background:{BG_CARD};border:1px solid {BORDER};border-left:3px solid {ACCENT_SOFT};" |
|
|
|
|
f'border-radius:11px;padding:12px 14px 12px 13px;margin-bottom:11px">' |
|
|
|
|
f"<div style='color:{TEXT_MUTED};font-size:12px;font-weight:600'>" |
|
|
|
|
f"Kind {kind_lbl} · {html.escape(t_human)}</div>" |
|
|
|
|
f"<div style='color:{TEXT_DIM};margin-top:8px;font-size:15px;line-height:1.45'>" |
|
|
|
|
f"{html.escape(snip)}</div>" |
|
|
|
|
f"<div style='color:{ACCENT_SOFT};font-size:12px;margin-top:8px;font-weight:600'>" |
|
|
|
|
f"Open in new tab →</div></a>" |
|
|
|
|
) |
|
|
|
|
_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}" |
|
|
|
|
_no_notes = "No matching notes stored yet." |
|
|
|
|
|
|
|
|
|
card_style = ( |
|
|
|
|
f"background:{BG_CARD};border:1px solid {BORDER};border-radius:14px;" |
|
|
|
|
f"padding:16px 18px;margin-bottom:14px" |
|
|
|
|
) |
|
|
|
|
left_parts: list[str] = [] |
|
|
|
|
if about_inner: |
|
|
|
|
left_parts.append(f'<div style="{card_style}">{about_inner}</div>') |
|
|
|
|
if pay_card: |
|
|
|
|
left_parts.append(f'<div style="{card_style}">{pay_card}</div>') |
|
|
|
|
left_parts.append(f'<div style="{card_style}">{relay_inner}</div>') |
|
|
|
|
left_parts.append(f'<div style="{card_style}">{follow_inner}</div>') |
|
|
|
|
left_parts.append(f'<div style="{card_style}">{emoji_inner}</div>') |
|
|
|
|
left_parts.append(f'<div style="{card_style}">{json_inner}</div>') |
|
|
|
|
|
|
|
|
|
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>" |
|
|
|
|
) |
|
|
|
|
left_doc = ( |
|
|
|
|
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">" |
|
|
|
|
f"{FEED_DOC_CSS}</head><body style='padding:14px 16px 28px 18px'>" |
|
|
|
|
f"{''.join(left_parts)}</body></html>" |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
doc = ( |
|
|
|
|
n_notes = len(notes) |
|
|
|
|
feed_intro = ( |
|
|
|
|
f'<div style="margin-bottom:16px;padding-bottom:14px;border-bottom:1px solid {BORDER}">' |
|
|
|
|
f'<div style="font-size:20px;font-weight:700;color:{TEXT};letter-spacing:-0.02em">Posts</div>' |
|
|
|
|
f'<div style="color:{TEXT_DIM};font-size:14px;margin-top:6px;line-height:1.4">' |
|
|
|
|
f"{n_notes} recent note{'' if n_notes == 1 else 's'} indexed on this device. " |
|
|
|
|
f"Click a card to open the thread in a <b style='color:{TEXT}'>new tab</b>.</div></div>" |
|
|
|
|
) |
|
|
|
|
if note_lines: |
|
|
|
|
feed_inner = "".join(note_lines) |
|
|
|
|
else: |
|
|
|
|
feed_inner = f"<p style='color:{TEXT_DIM};font-size:15px'><i>{html.escape(_no_notes)}</i></p>" |
|
|
|
|
feed_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>" |
|
|
|
|
f"{FEED_DOC_CSS}</head><body style='padding:14px 14px 28px 12px'>" |
|
|
|
|
f"{feed_intro}{feed_inner}</body></html>" |
|
|
|
|
) |
|
|
|
|
self._body.setHtml(doc) |
|
|
|
|
self._left_body.setHtml(left_doc) |
|
|
|
|
self._feed_body.setHtml(feed_doc) |
|
|
|
|
tw = self.parentWidget() |
|
|
|
|
if isinstance(tw, QTabWidget): |
|
|
|
|
i = tw.indexOf(self) |
|
|
|
|
if i >= 0: |
|
|
|
|
tw.setTabText(i, self.tab_title()) |
|
|
|
|
|
|
|
|
|
def _on_anchor(self, url: QUrl) -> None: |
|
|
|
|
def _dispatch_profile_anchor(self, url: QUrl) -> None: |
|
|
|
|
s = url.toString() |
|
|
|
|
if url.scheme() == "imwald" and url.host() == "pub": |
|
|
|
|
tail = (url.path() or "").strip("/").lower() |
|
|
|
|
@ -300,7 +619,8 @@ class ProfilePage(QWidget):
@@ -300,7 +619,8 @@ class ProfilePage(QWidget):
|
|
|
|
|
if url.scheme() == "imwald" and url.host() == "note": |
|
|
|
|
eid = (url.path() or "").strip("/") |
|
|
|
|
if len(eid) == 64 and all(c in "0123456789abcdef" for c in eid.lower()): |
|
|
|
|
self.open_note.emit(eid.lower()) |
|
|
|
|
self.open_note_new_tab.emit(eid.lower()) |
|
|
|
|
return |
|
|
|
|
if s.startswith("https://") or s.startswith("http://"): |
|
|
|
|
QDesktopServices.openUrl(url) |
|
|
|
|
if not try_open_media_url_in_app(self.window(), url): |
|
|
|
|
QDesktopServices.openUrl(url) |
|
|
|
|
|