Browse Source

bug-fixes

master
Silberengel 2 weeks ago
parent
commit
02bd66e331
  1. 50
      src/imwald/core/author_html.py
  2. 18
      src/imwald/core/nostr_engine.py
  3. 42
      src/imwald/ui/feed_page.py
  4. 99
      src/imwald/ui/main_window.py
  5. 42
      src/imwald/ui/theme.py

50
src/imwald/core/author_html.py

@ -8,6 +8,15 @@ from imwald.core.kind0_profile import display_name_from_profile, display_name_fr @@ -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( @@ -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( @@ -83,15 +94,24 @@ def format_nip05_chips_html(
mark = f'<span style="color:{bad}" title="NIP-05 not verified">✗</span>'
else:
mark = f'<span style="color:{dim}" title="NIP-05 not verified yet">○</span>'
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'<span style="display:inline-flex;align-items:center;gap:5px;margin:4px 14px 0 0">'
f'<span style="{wrap}">'
f"{icon}"
f'<span style="color:{muted};font-size:14px">{esc}</span>{mark}'
f'<span style="color:{muted};font-size:13px">{esc}</span>{mark}'
f"</span>"
)
if not parts:
return ""
return f'<div style="display:flex;flex-wrap:wrap;align-items:center;margin-top:4px">{"".join(parts)}</div>'
mt = "12px" if chip_bg else "4px"
return f'<div style="display:flex;flex-wrap:wrap;align-items:center;margin-top:{mt}">{"".join(parts)}</div>'
def feed_op_author_compact_html(
@ -103,21 +123,31 @@ 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'<div style="margin-top:4px" class="md">{status_inner_html}</div>'
status_block = (
f'<div style="margin-top:12px;padding-top:12px;border-top:1px solid {border}" class="md">'
f"{status_inner_html}</div>"
)
return (
f'<div style="display:flex;align-items:flex-start;margin-bottom:12px;gap:12px">'
f'<div style="display:flex;align-items:flex-start;gap:20px;margin:0 0 24px 0;'
f'padding:4px 4px 22px 2px;border-bottom:1px solid {border}">'
f"{av}"
f'<div style="flex:1;min-width:0">'
f'<div style="flex:1;min-width:0;padding-top:2px">'
f'<a href="{href}" style="text-decoration:none;color:inherit" title="View profile">'
f'<div style="color:{muted};font-size:15px;font-weight:500;word-break:break-all">{npub_e}</div>'
f'<div style="color:{text};font-size:22px;font-weight:700;letter-spacing:-0.03em;line-height:1.2">'
f"{name_e}</div>"
f'<div style="margin-top:10px;font-size:13px;color:{dim};font-family:ui-monospace,Cascadia Mono,Consolas,'
f'monospace;line-height:1.45;word-break:break-all" title="{npub_title}">{npub_line}</div>'
f"</a>"
f"{status_block}"
f"{nip05_chips_html}"

18
src/imwald/core/nostr_engine.py

@ -43,6 +43,7 @@ class NostrEngine(QObject): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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

42
src/imwald/ui/feed_page.py

@ -6,6 +6,7 @@ import html @@ -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 @@ -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): @@ -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): @@ -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"<div style='color:{TEXT_DIM};font-size:15px;margin:6px 0'><i>Trending slice (nostrarchives)</i></div>"
eid = html.escape(str(ev["id"]))
tr = (
f"<div style='color:{TEXT_MUTED};font-size:13px;margin:0 0 12px 0;font-style:italic'>"
f"Trending slice (nostrarchives)</div>"
)
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): @@ -459,12 +482,15 @@ class FeedPage(QWidget):
)
return (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body>"
f"{FEED_DOC_CSS}</head><body style=\"padding:16px 22px 28px 22px\">"
f"{author_block}"
f"<div style='color:{TEXT_DIM};font-size:15px;margin-bottom:8px'>Kind {int(ev['kind'])} · {int(ev['created_at'])}</div>"
f"<div style='color:{TEXT_DIM};font-size:14px;margin:0 0 16px 0;line-height:1.5'>"
f"<span style='color:{TEXT_MUTED};font-size:12px;font-weight:600'>Kind {int(ev['kind'])}</span>"
f"<span style='color:{TEXT_MUTED};margin:0 8px'>·</span>{t_human_e}</div>"
f"{tr}"
f"<div class=\"md\">{md_body}</div>"
f"<p style='color:{TEXT_DIM};font-size:14px;margin-top:14px'>{eid}</p>"
f'<div class="md" style="margin-top:4px">{md_body}</div>'
f'<p style="color:{TEXT_DIM};font-size:12px;margin:22px 0 0 0;font-family:ui-monospace,monospace;'
f'line-height:1.4" title="{eid_title}">{eid_display}</p>'
"</body></html>"
)

99
src/imwald/ui/main_window.py

@ -7,10 +7,12 @@ from typing import Any, cast @@ -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 ( @@ -18,6 +20,7 @@ from PySide6.QtWidgets import (
QListWidgetItem,
QMainWindow,
QMessageBox,
QProgressBar,
QSpinBox,
QSplitter,
QStackedWidget,
@ -139,6 +142,30 @@ class MainWindow(QMainWindow): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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)

42
src/imwald/ui/theme.py

@ -2,6 +2,8 @@ @@ -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" @@ -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'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">'
b'<path d="M3 3l8 8M11 3l-8 8" stroke="#c5e0d4" stroke-width="2" stroke-linecap="round" fill="none"/>'
b"</svg>"
).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"""
<style>
@ -63,6 +73,16 @@ QStatusBar {{ @@ -63,6 +73,16 @@ QStatusBar {{
color: {TEXT_MUTED};
border-top: 1px solid {BORDER};
}}
QProgressBar#BusyBar {{
background-color: {BG_WINDOW};
border: 1px solid {BORDER};
border-radius: 5px;
padding: 1px;
}}
QProgressBar#BusyBar::chunk {{
background-color: {ACCENT_SOFT};
border-radius: 3px;
}}
QPushButton {{
background-color: {BG_CARD};
color: {TEXT};
@ -127,7 +147,7 @@ QTabBar::tab {{ @@ -127,7 +147,7 @@ QTabBar::tab {{
color: {TEXT_DIM};
font-size: 15px;
min-height: 22px;
padding: 10px 22px 11px 20px;
padding: 10px 36px 11px 18px;
margin: 8px 5px 0 0;
border: 1px solid {BORDER};
border-bottom: none;
@ -138,7 +158,7 @@ QTabBar::tab:selected {{ @@ -138,7 +158,7 @@ QTabBar::tab:selected {{
background-color: {BG_FIELD};
color: {TEXT};
font-weight: 600;
padding-bottom: 12px;
padding: 10px 36px 12px 18px;
margin-bottom: -1px;
border-color: {BORDER};
border-bottom-color: {BG_FIELD};
@ -150,18 +170,22 @@ QTabBar::tab:!selected:hover {{ @@ -150,18 +170,22 @@ QTabBar::tab:!selected:hover {{
QTabBar::close-button {{
subcontrol-origin: padding;
subcontrol-position: right;
width: 22px;
height: 22px;
margin: 0 2px 0 8px;
padding: 4px;
border-radius: 6px;
image: {_TAB_CLOSE_ICON_URL};
width: 26px;
height: 26px;
margin: 0 4px 0 6px;
padding: 5px;
border-radius: 8px;
background-color: {BG_CODE};
border: 1px solid {BORDER};
}}
QTabBar::close-button:hover {{
background-color: {BG_CARD};
border: 1px solid {ACCENT_SOFT};
border: 1px solid {ACCENT};
}}
QTabBar::close-button:pressed {{
background-color: {BG_CODE};
background-color: {BG_FIELD};
border: 1px solid {ACCENT_SOFT};
}}
QComboBox {{
background-color: {BG_FIELD};

Loading…
Cancel
Save