|
|
|
|
@ -7,6 +7,7 @@ import json
@@ -7,6 +7,7 @@ import json
|
|
|
|
|
from datetime import datetime, timezone |
|
|
|
|
from typing import cast |
|
|
|
|
|
|
|
|
|
from PySide6 import Shiboken |
|
|
|
|
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 |
|
|
|
|
@ -19,6 +20,7 @@ from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary
@@ -19,6 +20,7 @@ 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.asset_paths import default_banner_png_path |
|
|
|
|
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 ACCENT_SOFT, BG_CARD, BG_CODE, BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED |
|
|
|
|
@ -210,6 +212,11 @@ class _CoverImageSignals(QObject):
@@ -210,6 +212,11 @@ class _CoverImageSignals(QObject):
|
|
|
|
|
avatar_ready = Signal(object, int) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _qobject_alive(obj: QObject) -> bool: |
|
|
|
|
"""False after the wrapped Qt object has been destroyed (e.g. profile tab closed).""" |
|
|
|
|
return Shiboken.isValid(obj) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _ProfileHttpImageRunnable(QRunnable): |
|
|
|
|
def __init__(self, url: str, gen: int, mode: str, out: _CoverImageSignals) -> None: |
|
|
|
|
super().__init__() |
|
|
|
|
@ -233,6 +240,8 @@ class _ProfileHttpImageRunnable(QRunnable):
@@ -233,6 +240,8 @@ class _ProfileHttpImageRunnable(QRunnable):
|
|
|
|
|
except OSError: |
|
|
|
|
self._emit_fail() |
|
|
|
|
return |
|
|
|
|
if not _qobject_alive(self._out): |
|
|
|
|
return |
|
|
|
|
if self._mode == "banner": |
|
|
|
|
self._out.banner_ready.emit(pm, self._gen) |
|
|
|
|
elif self._mode == "avatar": |
|
|
|
|
@ -252,6 +261,8 @@ class _ProfileHttpImageRunnable(QRunnable):
@@ -252,6 +261,8 @@ class _ProfileHttpImageRunnable(QRunnable):
|
|
|
|
|
self._emit_fail() |
|
|
|
|
|
|
|
|
|
def _emit_fail(self) -> None: |
|
|
|
|
if not _qobject_alive(self._out): |
|
|
|
|
return |
|
|
|
|
if self._mode == "banner": |
|
|
|
|
self._out.banner_ready.emit(QPixmap(), self._gen) |
|
|
|
|
else: |
|
|
|
|
@ -291,7 +302,8 @@ class _ProfileLnurlRunnable(QRunnable):
@@ -291,7 +302,8 @@ class _ProfileLnurlRunnable(QRunnable):
|
|
|
|
|
|
|
|
|
|
def run(self) -> None: |
|
|
|
|
html = build_merged_lnurl_pay_section(self._urls) |
|
|
|
|
self._out.finished.emit(html, self._gen) |
|
|
|
|
if _qobject_alive(self._out): |
|
|
|
|
self._out.finished.emit(html, self._gen) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProfilePage(QWidget): |
|
|
|
|
@ -337,9 +349,11 @@ class ProfilePage(QWidget):
@@ -337,9 +349,11 @@ class ProfilePage(QWidget):
|
|
|
|
|
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]) |
|
|
|
|
# Narrower metadata column, wider posts — inverse of FeedPage (thread wide, OP narrower). |
|
|
|
|
split.setStretchFactor(0, 2) |
|
|
|
|
split.setStretchFactor(1, 3) |
|
|
|
|
split.setSizes([400, 780]) |
|
|
|
|
self._left_column.setMinimumWidth(300) |
|
|
|
|
self._feed_body.setMinimumWidth(260) |
|
|
|
|
lay = QVBoxLayout(self) |
|
|
|
|
lay.setContentsMargins(0, 0, 0, 0) |
|
|
|
|
@ -356,6 +370,10 @@ class ProfilePage(QWidget):
@@ -356,6 +370,10 @@ class ProfilePage(QWidget):
|
|
|
|
|
self._cov_sigs.avatar_ready.connect(self._on_cover_avatar_http) |
|
|
|
|
self._cov_pool = QThreadPool(self) |
|
|
|
|
self._cov_pool.setMaxThreadCount(3) |
|
|
|
|
# Last banner/picture HTTPS URLs we applied to the hero (including ""). Used to avoid |
|
|
|
|
# resetting placeholders + bumping fetch generations on every refresh() while ingest runs. |
|
|
|
|
self._cover_banner_http_key: str | None = None |
|
|
|
|
self._cover_avatar_http_key: str | None = None |
|
|
|
|
self.refresh() |
|
|
|
|
|
|
|
|
|
def tab_title(self) -> str: |
|
|
|
|
@ -437,28 +455,40 @@ class ProfilePage(QWidget):
@@ -437,28 +455,40 @@ class ProfilePage(QWidget):
|
|
|
|
|
else "" |
|
|
|
|
) |
|
|
|
|
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)) |
|
|
|
|
b_key = banner_https or "" |
|
|
|
|
banner_dirty = self._cover_banner_http_key is None or self._cover_banner_http_key != b_key |
|
|
|
|
p_key = picture_https or "" |
|
|
|
|
avatar_dirty = self._cover_avatar_http_key is None or self._cover_avatar_http_key != p_key |
|
|
|
|
|
|
|
|
|
if banner_dirty: |
|
|
|
|
self._cover_banner_http_key = b_key |
|
|
|
|
ph_b = QPixmap(str(default_banner_png_path())) |
|
|
|
|
if ph_b.isNull(): |
|
|
|
|
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()) |
|
|
|
|
self._cover_banner_gen += 1 |
|
|
|
|
b_gen = self._cover_banner_gen |
|
|
|
|
if banner_https: |
|
|
|
|
self._cov_pool.start(_ProfileHttpImageRunnable(banner_https, b_gen, "banner", self._cov_sigs)) |
|
|
|
|
|
|
|
|
|
if avatar_dirty: |
|
|
|
|
self._cover_avatar_http_key = p_key |
|
|
|
|
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_avatar_gen += 1 |
|
|
|
|
a_gen = self._cover_avatar_gen |
|
|
|
|
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: |
|
|
|
|
|