diff --git a/src/imwald/core/author_html.py b/src/imwald/core/author_html.py index 1903bac..44dfba2 100644 --- a/src/imwald/core/author_html.py +++ b/src/imwald/core/author_html.py @@ -8,6 +8,15 @@ from imwald.core.kind0_profile import display_name_from_profile, display_name_fr from imwald.core.nip05 import favicon_url_for_domain, parse_nip05_identifier +def _npub_header_lines(npub_bech: str) -> tuple[str, str]: + """Return (escaped one-line npub for display, escaped full npub for title/tooltip).""" + n = npub_bech.strip() + full_esc = html.escape(n, quote=True) + if len(n) <= 44: + return html.escape(n, quote=False), full_esc + return html.escape(n[:22] + "…" + n[-18:], quote=False), full_esc + + def safe_http_url(u: str | None) -> str | None: if not u: return None @@ -58,6 +67,8 @@ def format_nip05_chips_html( dim: str, ok: str, bad: str, + chip_bg: str | None = None, + chip_border: str | None = None, ) -> str: """ Horizontal row of NIP-05 chips: favicon (per domain), identifier, verification mark. @@ -83,15 +94,24 @@ def format_nip05_chips_html( mark = f'' else: mark = f'' + if chip_bg: + brd = chip_border or dim + wrap = ( + "display:inline-flex;align-items:center;gap:7px;padding:6px 12px;margin:8px 8px 0 0;" + f"border-radius:999px;background:{chip_bg};border:1px solid {brd}" + ) + else: + wrap = "display:inline-flex;align-items:center;gap:5px;margin:4px 14px 0 0" parts.append( - f'' + f'' f"{icon}" - f'{esc}{mark}' + f'{esc}{mark}' f"" ) if not parts: return "" - return f'
{"".join(parts)}
' + mt = "12px" if chip_bg else "4px" + return f'
{"".join(parts)}
' def feed_op_author_compact_html( @@ -103,21 +123,31 @@ def feed_op_author_compact_html( nip05_chips_html: str, muted: str, border: str, + text: str, + dim: str, ) -> str: - """Feed OP header: avatar, npub only, optional NIP-38 status (HTML), NIP-05 chips.""" + """Feed OP header: larger avatar, display name, compact npub line, NIP-38 + NIP-05.""" pk_l = pubkey_hex.strip().lower() href = html.escape(f"imwald://pub/{pk_l}", quote=True) - av = avatar_img_or_placeholder(parsed, 52, border_hex=border, profile_href=href) - npub_e = html.escape(npub_bech) + av = avatar_img_or_placeholder(parsed, 64, border_hex=border, profile_href=href) + name_e = html.escape(display_name_from_profile_or_hex(parsed, pubkey_hex)) + npub_line, npub_title = _npub_header_lines(npub_bech) status_block = "" if status_inner_html.strip(): - status_block = f'
{status_inner_html}
' + status_block = ( + f'
' + f"{status_inner_html}
" + ) return ( - f'
' + f'
' f"{av}" - f'
' + f'
' f'' - f'
{npub_e}
' + f'
' + f"{name_e}
" + f'
{npub_line}
' f"
" f"{status_block}" f"{nip05_chips_html}" diff --git a/src/imwald/core/nostr_engine.py b/src/imwald/core/nostr_engine.py index 7126a6f..22da0d5 100644 --- a/src/imwald/core/nostr_engine.py +++ b/src/imwald/core/nostr_engine.py @@ -43,6 +43,7 @@ class NostrEngine(QObject): event_ingested = Signal(str, object) relay_status = Signal(str) relay_snapshot = Signal(object) + relay_connect_ready = Signal(int) def __init__(self, db: Database) -> None: super().__init__() @@ -55,6 +56,7 @@ class NostrEngine(QObject): self._read_urls_snapshot: list[str] = [] self._author_meta_pending: set[str] = set() self._author_meta_timer: asyncio.TimerHandle | None = None + self._relay_connect_cookie = 0 def start_relays( self, @@ -62,14 +64,18 @@ class NostrEngine(QObject): *, user_write_urls: list[str] | None = None, list30000_owner: str | None = None, - ) -> None: + ) -> int: if self._thread and self._thread.is_alive(): self.stop_relays() + self._relay_connect_cookie += 1 + connect_cookie = self._relay_connect_cookie urls = list(read_urls or default_feed_read_relays()) aggr_writes = set(user_write_urls or list(DEFAULT_WRITE_RELAYS)) k3000_owner = (list30000_owner or "").strip().lower() def runner() -> None: + cookie = connect_cookie + async def on_ev(url: str, ev: dict[str, Any]) -> None: self.event_ingested.emit(url, ev) @@ -116,8 +122,13 @@ class NostrEngine(QObject): "relay worker: %d relay(s) registered, calling connect_all()", len(mgr.all_relays()), ) - await mgr.connect_all() - log.info("relay worker: connect_all() returned (per-relay runners active)") + try: + await mgr.connect_all() + log.info("relay worker: connect_all() returned (per-relay runners active)") + except Exception: + log.exception("relay worker: connect_all() failed") + finally: + self.relay_connect_ready.emit(cookie) async def relay_pulse() -> None: while not app_stop.is_set(): @@ -159,6 +170,7 @@ class NostrEngine(QObject): self._thread = threading.Thread(target=runner, name="nostr-relay", daemon=True) self._thread.start() log.info("relay worker thread started (%d read URL(s))", len(urls)) + return connect_cookie def stop_relays(self) -> None: loop = self._loop diff --git a/src/imwald/ui/feed_page.py b/src/imwald/ui/feed_page.py index 56465af..eec05c2 100644 --- a/src/imwald/ui/feed_page.py +++ b/src/imwald/ui/feed_page.py @@ -6,6 +6,7 @@ import html import json import re from collections.abc import Sequence +from datetime import datetime, timezone from typing import Any, cast from PySide6.QtCore import QEvent, QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, QUrl @@ -43,7 +44,7 @@ from imwald.core.nostr_engine import NostrEngine from imwald.core.ranker import Ranker 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, BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED +from imwald.ui.theme import ACCENT, BG_CODE, BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED FEED_KINDS = (1, 20, 21, 30023, 9802, 11) @@ -426,7 +427,13 @@ class FeedPage(QWidget): vmap = {a.strip().lower(): b for a, b in nip05_verified} states = [(c, vmap.get(c.strip().lower(), False)) for c in candidates] chips = format_nip05_chips_html( - states, muted=TEXT_MUTED, dim=TEXT_DIM, ok=ACCENT, bad="#b86a6a" + states, + muted=TEXT_MUTED, + dim=TEXT_DIM, + ok=ACCENT, + bad="#b86a6a", + chip_bg=BG_CODE, + chip_border=BORDER, ) st_ev = get_active_user_status_event(self._db, pk) st_html = "" @@ -445,12 +452,28 @@ class FeedPage(QWidget): nip05_chips_html=chips, muted=TEXT_MUTED, border=BORDER, + text=TEXT, + dim=TEXT_DIM, ) tr = "" sr = ev.get("source_relay") or "" if sr and "nostrarchives.com" in sr: - tr = f"
Trending slice (nostrarchives)
" - eid = html.escape(str(ev["id"])) + tr = ( + f"
" + f"Trending slice (nostrarchives)
" + ) + try: + cts = int(ev["created_at"]) + t_human = datetime.fromtimestamp(cts, tz=timezone.utc).strftime("%Y-%m-%d · %H:%M UTC") + except (TypeError, ValueError, OSError): + t_human = str(ev.get("created_at") or "") + t_human_e = html.escape(t_human) + eid_raw = str(ev["id"]) + if len(eid_raw) > 52: + eid_display = html.escape(eid_raw[:22] + "…" + eid_raw[-18:]) + else: + eid_display = html.escape(eid_raw) + eid_title = html.escape(eid_raw, quote=True) md_body = markdown_html_fragment( ev.get("content") or "", db=self._db, @@ -459,12 +482,15 @@ class FeedPage(QWidget): ) return ( "" - f"{FEED_DOC_CSS}" + f"{FEED_DOC_CSS}" f"{author_block}" - f"
Kind {int(ev['kind'])} · {int(ev['created_at'])}
" + f"
" + f"Kind {int(ev['kind'])}" + f"·{t_human_e}
" f"{tr}" - f"
{md_body}
" - f"

{eid}

" + f'
{md_body}
' + f'

{eid_display}

' "" ) diff --git a/src/imwald/ui/main_window.py b/src/imwald/ui/main_window.py index 7329bc2..f7ff758 100644 --- a/src/imwald/ui/main_window.py +++ b/src/imwald/ui/main_window.py @@ -7,10 +7,12 @@ from typing import Any, cast 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 ( + QApplication, QComboBox, QDialog, QDialogButtonBox, QFormLayout, + QHBoxLayout, QInputDialog, QLabel, QLineEdit, @@ -18,6 +20,7 @@ from PySide6.QtWidgets import ( QListWidgetItem, QMainWindow, QMessageBox, + QProgressBar, QSpinBox, QSplitter, QStackedWidget, @@ -139,6 +142,30 @@ class MainWindow(QMainWindow): split.setSizes([920, 280]) self.setCentralWidget(split) + self._busy_depth = 0 + self._awaiting_relay_cookie: int | None = None + self._busy_wrap = QWidget() + self._busy_wrap.setObjectName("BusyWrap") + bh = QHBoxLayout(self._busy_wrap) + bh.setContentsMargins(0, 0, 8, 0) + bh.setSpacing(10) + self._busy_label = QLabel("") + self._busy_label.setStyleSheet(f"color: {TEXT_MUTED}; font-size: 13px;") + self._busy_bar = QProgressBar() + self._busy_bar.setObjectName("BusyBar") + self._busy_bar.setRange(0, 0) + self._busy_bar.setTextVisible(False) + self._busy_bar.setFixedSize(132, 10) + self._busy_bar.setMaximumHeight(12) + bh.addWidget(self._busy_label) + bh.addWidget(self._busy_bar) + self._busy_wrap.hide() + self.statusBar().addPermanentWidget(self._busy_wrap) + self._relay_connect_timer = QTimer(self) + self._relay_connect_timer.setSingleShot(True) + self._relay_connect_timer.setInterval(45_000) + self._relay_connect_timer.timeout.connect(self._on_relay_connect_timeout) + self._acct_combo = QComboBox() self._acct_combo.setMinimumWidth(220) self._acct_combo.setIconSize(QSize(22, 22)) @@ -172,8 +199,12 @@ class MainWindow(QMainWindow): self._on_account_changed() if not self._db.get_setting("onboarding_done") and not self._accounts: - if run_onboarding_wizard(self, db=self._db, engine=self._engine, existing_accounts=self._accounts): - self._db.set_setting("onboarding_done", "1") + self.push_busy("Opening wizard…") + try: + if run_onboarding_wizard(self, db=self._db, engine=self._engine, existing_accounts=self._accounts): + self._db.set_setting("onboarding_done", "1") + finally: + self.pop_busy() self._accounts = load_accounts() self._reload_account_combo() self._notif.set_accounts(self._accounts) @@ -283,15 +314,52 @@ class MainWindow(QMainWindow): self._feed.reload_queue() self._restart_relays() + def push_busy(self, message: str = "Working…") -> None: + """Show the status-bar spinner (supports nested push/pop).""" + self._busy_depth += 1 + if self._busy_depth == 1: + self._busy_label.setText(message) + self._busy_wrap.show() + self.setCursor(Qt.CursorShape.WaitCursor) + QApplication.processEvents() + + def pop_busy(self) -> None: + if self._busy_depth <= 0: + return + self._busy_depth -= 1 + if self._busy_depth == 0: + self._busy_wrap.hide() + self.unsetCursor() + + def _on_relay_connect_ready(self, cookie: int) -> None: + if self._awaiting_relay_cookie != cookie: + return + self._awaiting_relay_cookie = None + self._relay_connect_timer.stop() + self.pop_busy() + + def _on_relay_connect_timeout(self) -> None: + if self._awaiting_relay_cookie is None: + return + self._awaiting_relay_cookie = None + self.pop_busy() + def _restart_relays(self) -> None: + self.push_busy("Connecting to relays…") pk = self._current_pubkey() resolved = resolve_for_account(self._db, pk) reads = augment_feed_with_trending(resolved.read_urls) - self._engine.start_relays( - read_urls=reads, - user_write_urls=resolved.write_urls, - list30000_owner=self.list_owner_pubkey_for_relays(), - ) + + def _go() -> None: + ck = self._engine.start_relays( + read_urls=reads, + user_write_urls=resolved.write_urls, + list30000_owner=self.list_owner_pubkey_for_relays(), + ) + self._awaiting_relay_cookie = ck + self._relay_connect_timer.start() + + QTimer.singleShot(0, _go) self._author_bootstrap_timer.start() def _bootstrap_author_metadata_queue(self) -> None: @@ -395,6 +463,7 @@ class MainWindow(QMainWindow): self._engine.event_ingested.connect(self._on_event_ingested) self._engine.relay_status.connect(self._relay_status_message) self._engine.relay_status.connect(self._relay_panel.log_line.emit) + self._engine.relay_connect_ready.connect(self._on_relay_connect_ready) def _relay_status_message(self, s: str) -> None: self.statusBar().showMessage(s, 8000) @@ -515,7 +584,11 @@ class MainWindow(QMainWindow): return if QMessageBox.question(self, "NIP-09", f"Publish deletion for {event_id[:16]}…?") != QMessageBox.StandardButton.Yes: return - self._engine.publish_nip09_deletion(acc, pw, event_id) + self.push_busy("Publishing deletion…") + try: + self._engine.publish_nip09_deletion(acc, pw, event_id) + finally: + self.pop_busy() QMessageBox.information(self, "NIP-09", "Deletion request published to your write relays.") def _edit_current(self) -> None: @@ -581,7 +654,11 @@ class MainWindow(QMainWindow): def _onboarding_again(self) -> None: self._accounts = load_accounts() - run_onboarding_wizard(self, db=self._db, engine=self._engine, existing_accounts=self._accounts) + self.push_busy("Opening wizard…") + try: + run_onboarding_wizard(self, db=self._db, engine=self._engine, existing_accounts=self._accounts) + finally: + self.pop_busy() self._accounts = load_accounts() self._reload_account_combo() self._notif.set_accounts(self._accounts) @@ -604,6 +681,10 @@ class MainWindow(QMainWindow): return acc, self._password_for(pk) def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 + self._relay_connect_timer.stop() + self._awaiting_relay_cookie = None + while self._busy_depth > 0: + self.pop_busy() self._relay_panel.shutdown_logging() self._engine.stop_relays() super().closeEvent(event) diff --git a/src/imwald/ui/theme.py b/src/imwald/ui/theme.py index b5378a1..a835532 100644 --- a/src/imwald/ui/theme.py +++ b/src/imwald/ui/theme.py @@ -2,6 +2,8 @@ from __future__ import annotations +import base64 + from PySide6.QtWidgets import QApplication from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX @@ -19,6 +21,14 @@ BG_CARD = "#151f1a" BORDER = "#2a3d34" BG_CODE = "#0a100d" +# Fusion’s default tab-close pixmap is nearly invisible on our dark tabs; use an explicit “×”. +_TAB_CLOSE_ICON_B64 = base64.standard_b64encode( + b'' + b'' + b"" +).decode("ascii") +_TAB_CLOSE_ICON_URL = f'url("data:image/svg+xml;base64,{_TAB_CLOSE_ICON_B64}")' + _W = IMAGE_DISPLAY_MAX_WIDTH_PX FEED_DOC_CSS = f"""