|
|
|
|
@ -4,8 +4,8 @@ from __future__ import annotations
@@ -4,8 +4,8 @@ from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import Any, cast |
|
|
|
|
|
|
|
|
|
from PySide6.QtCore import Qt, QTimer |
|
|
|
|
from PySide6.QtGui import QAction, QCloseEvent |
|
|
|
|
from PySide6.QtCore import QObject, QRunnable, QSize, QThreadPool, Qt, QTimer, Signal |
|
|
|
|
from PySide6.QtGui import QAction, QCloseEvent, QColor, QIcon, QPainter, QPen, QPixmap |
|
|
|
|
from PySide6.QtWidgets import ( |
|
|
|
|
QComboBox, |
|
|
|
|
QDialog, |
|
|
|
|
@ -25,6 +25,7 @@ from PySide6.QtWidgets import (
@@ -25,6 +25,7 @@ from PySide6.QtWidgets import (
|
|
|
|
|
|
|
|
|
|
from imwald.core.accounts_store import StoredAccount, load_accounts |
|
|
|
|
from imwald.core.database import Database |
|
|
|
|
from imwald.core.kind0_profile import parse_kind0_profile |
|
|
|
|
from imwald.core.nostr_engine import AUTHOR_METADATA_KINDS, NostrEngine |
|
|
|
|
from imwald.core.md_render import markdown_plain_summary |
|
|
|
|
from imwald.core.relay_list import resolve_for_account |
|
|
|
|
@ -36,6 +37,56 @@ from imwald.ui.notifications_page import NotificationsPage
@@ -36,6 +37,56 @@ from imwald.ui.notifications_page import NotificationsPage
|
|
|
|
|
from imwald.ui.onboarding_wizard import run_onboarding_wizard |
|
|
|
|
from imwald.ui.relay_status_panel import RelayStatusPanel |
|
|
|
|
from imwald.ui.search_page import SearchPage |
|
|
|
|
from imwald.ui.theme import ACCENT, BG_FIELD, BORDER, TEXT_MUTED |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _https_profile_picture_url(raw: str | None) -> str | None: |
|
|
|
|
if not raw: |
|
|
|
|
return None |
|
|
|
|
u = raw.strip() |
|
|
|
|
return u if u.startswith("https://") else None |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _square_avatar_pixmap(pm: QPixmap, size: int) -> QPixmap: |
|
|
|
|
if pm.isNull(): |
|
|
|
|
return pm |
|
|
|
|
s = min(pm.width(), pm.height()) |
|
|
|
|
if s <= 0: |
|
|
|
|
return QPixmap() |
|
|
|
|
x = (pm.width() - s) // 2 |
|
|
|
|
y = (pm.height() - s) // 2 |
|
|
|
|
cropped = pm.copy(x, y, s, s) |
|
|
|
|
return cropped.scaled( |
|
|
|
|
size, size, Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _AcctPicSignals(QObject): |
|
|
|
|
loaded = Signal(str, object, int) # pubkey hex, QPixmap | None, combo_generation |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _AcctPicRunnable(QRunnable): |
|
|
|
|
def __init__(self, pubkey: str, url: str, rev: int, sigs: _AcctPicSignals) -> None: |
|
|
|
|
super().__init__() |
|
|
|
|
self._pubkey = pubkey |
|
|
|
|
self._url = url |
|
|
|
|
self._rev = rev |
|
|
|
|
self._sigs = sigs |
|
|
|
|
|
|
|
|
|
def run(self) -> None: |
|
|
|
|
pm: QPixmap | None = None |
|
|
|
|
try: |
|
|
|
|
from urllib.request import Request, urlopen |
|
|
|
|
|
|
|
|
|
req = Request(self._url, headers={"User-Agent": "imwald/1"}, method="GET") |
|
|
|
|
with urlopen(req, timeout=8) as resp: # noqa: S310 |
|
|
|
|
data = resp.read() |
|
|
|
|
loaded = QPixmap() |
|
|
|
|
if loaded.loadFromData(data) and not loaded.isNull(): |
|
|
|
|
pm = _square_avatar_pixmap(loaded, 24) |
|
|
|
|
except OSError: |
|
|
|
|
pm = None |
|
|
|
|
self._sigs.loaded.emit(self._pubkey, pm, self._rev) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MainWindow(QMainWindow): |
|
|
|
|
@ -71,6 +122,12 @@ class MainWindow(QMainWindow):
@@ -71,6 +122,12 @@ class MainWindow(QMainWindow):
|
|
|
|
|
|
|
|
|
|
self._acct_combo = QComboBox() |
|
|
|
|
self._acct_combo.setMinimumWidth(220) |
|
|
|
|
self._acct_combo.setIconSize(QSize(22, 22)) |
|
|
|
|
self._acct_combo_rev = 0 |
|
|
|
|
self._acct_pic_sigs = _AcctPicSignals(self) |
|
|
|
|
self._acct_pic_sigs.loaded.connect(self._on_account_avatar_loaded) |
|
|
|
|
self._acct_pic_pool = QThreadPool(self) |
|
|
|
|
self._acct_pic_pool.setMaxThreadCount(4) |
|
|
|
|
self._reload_account_combo() |
|
|
|
|
|
|
|
|
|
tb = QToolBar() |
|
|
|
|
@ -105,14 +162,75 @@ class MainWindow(QMainWindow):
@@ -105,14 +162,75 @@ class MainWindow(QMainWindow):
|
|
|
|
|
self._on_account_changed() |
|
|
|
|
|
|
|
|
|
def _reload_account_combo(self) -> None: |
|
|
|
|
self._acct_combo_rev += 1 |
|
|
|
|
rev = self._acct_combo_rev |
|
|
|
|
self._acct_combo.blockSignals(True) |
|
|
|
|
self._acct_combo.clear() |
|
|
|
|
self._acct_combo.addItem("Lurk (no key)", "") |
|
|
|
|
self._acct_combo.setItemIcon(0, self._account_combo_placeholder_icon("?", lurk=True)) |
|
|
|
|
for a in self._accounts: |
|
|
|
|
label = a.label or a.pubkey[:12] + "…" |
|
|
|
|
self._acct_combo.addItem(label, a.pubkey) |
|
|
|
|
idx = self._acct_combo.count() - 1 |
|
|
|
|
self._acct_combo.setItemIcon(idx, self._account_combo_placeholder_icon(label, lurk=False)) |
|
|
|
|
row = self._db.get_latest_kind0_profile(a.pubkey) |
|
|
|
|
pic = parse_kind0_profile(row["content"] if row else "").get("picture") |
|
|
|
|
url = _https_profile_picture_url(pic) |
|
|
|
|
if url: |
|
|
|
|
self._acct_pic_pool.start(_AcctPicRunnable(a.pubkey, url, rev, self._acct_pic_sigs)) |
|
|
|
|
self._acct_combo.blockSignals(False) |
|
|
|
|
|
|
|
|
|
def _account_combo_placeholder_icon(self, label: str, *, lurk: bool) -> QIcon: |
|
|
|
|
sz = 24 |
|
|
|
|
pm = QPixmap(sz, sz) |
|
|
|
|
pm.fill(Qt.GlobalColor.transparent) |
|
|
|
|
painter = QPainter(pm) |
|
|
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing) |
|
|
|
|
painter.setBrush(QColor(BG_FIELD)) |
|
|
|
|
painter.setPen(QPen(QColor(BORDER), 1)) |
|
|
|
|
painter.drawRoundedRect(1, 1, sz - 2, sz - 2, 5, 5) |
|
|
|
|
painter.setPen(QColor(ACCENT if not lurk else TEXT_MUTED)) |
|
|
|
|
font = painter.font() |
|
|
|
|
font.setBold(True) |
|
|
|
|
font.setPointSize(10) |
|
|
|
|
painter.setFont(font) |
|
|
|
|
ch = "…" if lurk else "" |
|
|
|
|
if not lurk and label: |
|
|
|
|
for c in label.strip(): |
|
|
|
|
if c.isalnum(): |
|
|
|
|
ch = c.upper() |
|
|
|
|
break |
|
|
|
|
if not ch: |
|
|
|
|
ch = "?" |
|
|
|
|
painter.drawText(pm.rect(), Qt.AlignmentFlag.AlignCenter, ch) |
|
|
|
|
painter.end() |
|
|
|
|
return QIcon(pm) |
|
|
|
|
|
|
|
|
|
def _on_account_avatar_loaded(self, pubkey: str, pm: object, rev: int) -> None: |
|
|
|
|
if rev != self._acct_combo_rev: |
|
|
|
|
return |
|
|
|
|
if not isinstance(pm, QPixmap) or pm.isNull(): |
|
|
|
|
return |
|
|
|
|
icon = QIcon(pm) |
|
|
|
|
pk_l = pubkey.strip().lower() |
|
|
|
|
for i in range(self._acct_combo.count()): |
|
|
|
|
d = self._acct_combo.itemData(i) |
|
|
|
|
if d and str(d).strip().lower() == pk_l: |
|
|
|
|
self._acct_combo.setItemIcon(i, icon) |
|
|
|
|
break |
|
|
|
|
|
|
|
|
|
def _maybe_refresh_account_avatar(self, pubkey_hex: str) -> None: |
|
|
|
|
"""Re-fetch combo avatar when a stored account's kind-0 was updated (no combo rebuild).""" |
|
|
|
|
pk_l = pubkey_hex.strip().lower() |
|
|
|
|
if not pk_l or not any(a.pubkey.lower() == pk_l for a in self._accounts): |
|
|
|
|
return |
|
|
|
|
row = self._db.get_latest_kind0_profile(pubkey_hex) |
|
|
|
|
pic = parse_kind0_profile(row["content"] if row else "").get("picture") |
|
|
|
|
url = _https_profile_picture_url(pic) |
|
|
|
|
if not url: |
|
|
|
|
return |
|
|
|
|
self._acct_pic_pool.start(_AcctPicRunnable(pubkey_hex, url, self._acct_combo_rev, self._acct_pic_sigs)) |
|
|
|
|
|
|
|
|
|
def _current_pubkey(self) -> str | None: |
|
|
|
|
d = self._acct_combo.currentData() |
|
|
|
|
return str(d) if d else None |
|
|
|
|
@ -229,6 +347,8 @@ class MainWindow(QMainWindow):
@@ -229,6 +347,8 @@ class MainWindow(QMainWindow):
|
|
|
|
|
ek = int(evd.get("kind", -1)) |
|
|
|
|
except (TypeError, ValueError): |
|
|
|
|
ek = -1 |
|
|
|
|
if ek == 0: |
|
|
|
|
self._maybe_refresh_account_avatar(pk) |
|
|
|
|
if ek not in AUTHOR_METADATA_KINDS: |
|
|
|
|
self._engine.enqueue_author_metadata(pk) |
|
|
|
|
self._ingest_ui_timer.start() |
|
|
|
|
|