diff --git a/src/imwald/core/author_html.py b/src/imwald/core/author_html.py index 8f0a955..ac381c3 100644 --- a/src/imwald/core/author_html.py +++ b/src/imwald/core/author_html.py @@ -4,7 +4,7 @@ from __future__ import annotations import html -from imwald.core.kind0_profile import display_name_from_profile +from imwald.core.kind0_profile import display_name_from_profile, display_name_from_profile_or_hex def safe_http_url(u: str | None) -> str | None: @@ -68,6 +68,7 @@ def thread_reply_author_row_html( parsed: dict[str, str | None], kind: int, npub_bech: str, + pubkey_hex: str, *, text: str, muted: str, @@ -76,7 +77,7 @@ def thread_reply_author_row_html( ) -> str: """Single-row author badge for thread replies (avatar + kind + name + npub).""" av = avatar_img_or_placeholder(parsed, 40, border_hex=border) - name = html.escape(display_name_from_profile(parsed)) + name = html.escape(display_name_from_profile_or_hex(parsed, pubkey_hex)) npub_e = html.escape(npub_bech) return ( '
' diff --git a/src/imwald/core/database.py b/src/imwald/core/database.py index 0542725..225202e 100644 --- a/src/imwald/core/database.py +++ b/src/imwald/core/database.py @@ -568,42 +568,64 @@ class Database: out.add(pk) return out - def list_replies_to(self, root_event: dict[str, Any], limit: int = 80) -> list[dict[str, Any]]: - """Events in ``THREAD_REPLY_KINDS`` that link the root via ``e``/``E``/``a``/``A``/``q`` (Jumble-style).""" + def list_replies_to(self, root_event: dict[str, Any], limit: int = 200) -> list[dict[str, Any]]: + """ + Events in ``THREAD_REPLY_KINDS`` that participate in the thread under ``root_event``. + + Includes replies that tag the root (``e`` / ``E`` / ``a`` / ``A`` / ``q``) and **nested** + replies that only tag their immediate parent, by expanding link targets transitively + (same idea as Jumble-style threads in a local snapshot). + """ targets = thread_root_link_targets(root_event) if not targets: return [] + root_id = str(root_event.get("id") or "").strip().lower() kind_ph = ",".join("?" * len(THREAD_REPLY_KINDS)) tag_ph = ",".join("?" * len(THREAD_LINK_TAG_NAMES)) - tgt_ph = ",".join("?" * len(targets)) - cur = self.conn().execute( - f""" - SELECT e.id, e.pubkey, e.created_at, e.kind, e.content, e.sig, e.tags_json - FROM events e - WHERE e.deleted = 0 AND e.kind IN ({kind_ph}) - AND EXISTS ( - SELECT 1 FROM tags t - WHERE t.event_id = e.id - AND t.name IN ({tag_ph}) - AND lower(t.value) IN ({tgt_ph}) - ) - ORDER BY e.created_at ASC, e.id ASC - LIMIT ? - """, - (*THREAD_REPLY_KINDS, *THREAD_LINK_TAG_NAMES, *targets, limit), - ) - return [ - { - "id": r["id"], - "pubkey": r["pubkey"], - "created_at": r["created_at"], - "kind": r["kind"], - "content": r["content"], - "sig": r["sig"], - "tags": cast(list[list[str]], json.loads(r["tags_json"] or "[]")), - } - for r in cur - ] + link_targets: set[str] = {x.lower() for x in targets} + if len(root_id) == 64 and all(c in "0123456789abcdef" for c in root_id): + link_targets.add(root_id) + collected: dict[str, dict[str, Any]] = {} + for _ in range(256): + lt = sorted(link_targets) + if not lt: + break + tgt_ph = ",".join("?" * len(lt)) + cur = self.conn().execute( + f""" + SELECT e.id, e.pubkey, e.created_at, e.kind, e.content, e.sig, e.tags_json + FROM events e + WHERE e.deleted = 0 AND e.kind IN ({kind_ph}) + AND lower(e.id) != ? + AND EXISTS ( + SELECT 1 FROM tags t + WHERE t.event_id = e.id + AND t.name IN ({tag_ph}) + AND lower(t.value) IN ({tgt_ph}) + ) + """, + (*THREAD_REPLY_KINDS, root_id, *THREAD_LINK_TAG_NAMES, *lt), + ) + new_ids: set[str] = set() + for r in cur: + rid = str(r["id"]).lower() + if rid == root_id or rid in collected: + continue + collected[rid] = { + "id": r["id"], + "pubkey": r["pubkey"], + "created_at": r["created_at"], + "kind": r["kind"], + "content": r["content"], + "sig": r["sig"], + "tags": cast(list[list[str]], json.loads(r["tags_json"] or "[]")), + } + new_ids.add(rid) + if not new_ids: + break + link_targets = new_ids + rows = sorted(collected.values(), key=lambda x: (int(x["created_at"]), str(x["id"]))) + return rows[:limit] def search_local(self, query: str, limit: int = 100) -> list[dict[str, Any]]: q = f"%{query}%" diff --git a/src/imwald/core/kind0_profile.py b/src/imwald/core/kind0_profile.py index 6bc5b76..4542f1f 100644 --- a/src/imwald/core/kind0_profile.py +++ b/src/imwald/core/kind0_profile.py @@ -42,3 +42,14 @@ def parse_kind0_profile(content: str) -> dict[str, str | None]: def display_name_from_profile(p: dict[str, str | None]) -> str: return (p.get("display_name") or p.get("name") or "").strip() or "anon" + + +def display_name_from_profile_or_hex(p: dict[str, str | None], pubkey_hex: str) -> str: + """Like ``display_name_from_profile`` but falls back to a short hex id when no name is set.""" + n = (p.get("display_name") or p.get("name") or "").strip() + if n: + return n + pk = pubkey_hex.strip().lower() + if len(pk) >= 16 and all(c in "0123456789abcdef" for c in pk): + return f"{pk[:12]}…" + return "anon" diff --git a/src/imwald/ui/feed_page.py b/src/imwald/ui/feed_page.py index afc9cd1..bad71d8 100644 --- a/src/imwald/ui/feed_page.py +++ b/src/imwald/ui/feed_page.py @@ -25,7 +25,7 @@ from PySide6.QtWidgets import ( ) from imwald.core.author_html import feed_op_author_block_html, thread_reply_author_row_html -from imwald.core.database import Database, THREAD_REPLY_KINDS +from imwald.core.database import Database from imwald.core.kind0_profile import parse_kind0_profile from imwald.core.md_render import markdown_html_fragment, markdown_to_plain_text from imwald.core.nip19 import encode_npub @@ -149,6 +149,7 @@ class FeedPage(QWidget): self._list30000_pubkeys: set[str] = set() self._rendered_op_id: str | None = None self._rendered_reply_sig: tuple[str, ...] | None = None + self._thread_card_by_eid: dict[str, QFrame] = {} self._engagement = QFrame() self._engagement.setObjectName("EngagementBar") @@ -178,9 +179,7 @@ class FeedPage(QWidget): self._why.setStyleSheet(f"color: {TEXT_MUTED}; font-size: 14px;") self._why.setWordWrap(True) - self._thread_title = QLabel( - f"Thread (kinds {', '.join(str(k) for k in THREAD_REPLY_KINDS)})" - ) + self._thread_title = QLabel("Thread") self._thread_title.setObjectName("ThreadTitle") self._thread_host = QWidget() self._thread_layout = QVBoxLayout(self._thread_host) @@ -350,6 +349,7 @@ class FeedPage(QWidget): self._db.mark_feed_viewed(self._feed_viewer_key(), ev["id"]) def _clear_thread_rows(self) -> None: + self._thread_card_by_eid.clear() while self._thread_layout.count(): item = self._thread_layout.takeAt(0) if item is None: @@ -404,6 +404,7 @@ class FeedPage(QWidget): replies = self._db.list_replies_to(ev) reply_sig = tuple(str(r["id"]) for r in replies) if root_id == self._rendered_op_id and reply_sig == self._rendered_reply_sig: + self._refresh_thread_reply_heads(ev) return self._rendered_op_id = root_id @@ -486,7 +487,14 @@ class FeedPage(QWidget): head_b.document().setDocumentMargin(2) head_b.setFixedHeight(56) row_html = thread_reply_author_row_html( - rp, rk, encode_npub(rpk), text=TEXT, muted=TEXT_MUTED, dim=TEXT_DIM, border=BORDER + rp, + rk, + encode_npub(rpk), + str(r.get("pubkey") or ""), + text=TEXT, + muted=TEXT_MUTED, + dim=TEXT_DIM, + border=BORDER, ) head_b.setHtml( "" @@ -508,12 +516,51 @@ class FeedPage(QWidget): body_te.installEventFilter(self) vl.addWidget(head_b) vl.addWidget(body_te) + self._thread_card_by_eid[str(r["id"])] = card self._thread_layout.addWidget(card) self._thread_layout.addStretch(1) finally: self._thread_scroll.setUpdatesEnabled(True) + self._engine.enqueue_author_metadata_many({str(r["pubkey"]) for r in replies if r.get("pubkey")}) self._refit_timer.start() + def _refresh_thread_reply_heads(self, root_ev: dict[str, Any]) -> None: + """Update reply author rows when kind-0 metadata arrives (same reply ids, no full rebuild).""" + if not self._thread_card_by_eid or self._rendered_reply_sig is None: + return + replies = self._db.list_replies_to(root_ev) + if tuple(str(r["id"]) for r in replies) != self._rendered_reply_sig: + return + pubkeys = [str(r["pubkey"]) for r in replies] + profiles = self._db.get_latest_kind0_profiles(pubkeys) + for r in replies: + eid = str(r["id"]) + card = self._thread_card_by_eid.get(eid) + if card is None: + continue + head_b = card.findChild(NoteTextBrowser, "ReplyHead") + if head_b is None: + continue + rpk = str(r["pubkey"]).lower() + pr = profiles.get(rpk) + rp = parse_kind0_profile(pr["content"] if pr else "") + rk = int(r["kind"]) + row_html = thread_reply_author_row_html( + rp, + rk, + encode_npub(rpk), + str(r.get("pubkey") or ""), + text=TEXT, + muted=TEXT_MUTED, + dim=TEXT_DIM, + border=BORDER, + ) + head_b.setHtml( + "" + f"{FEED_DOC_CSS}" + f"{row_html}" + ) + def _prev(self) -> None: if self._queue: self._index = (self._index - 1) % len(self._queue) diff --git a/src/imwald/ui/main_window.py b/src/imwald/ui/main_window.py index 5edefb4..1158139 100644 --- a/src/imwald/ui/main_window.py +++ b/src/imwald/ui/main_window.py @@ -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 ( 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 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): 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): 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): 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() diff --git a/tests/test_thread_links.py b/tests/test_thread_links.py index b4fd442..c085c1b 100644 --- a/tests/test_thread_links.py +++ b/tests/test_thread_links.py @@ -48,6 +48,37 @@ def test_list_replies_to_matches_q_tag() -> None: assert len(got) == 1 and got[0]["id"] == rep["id"] +def test_list_replies_to_nested_reply_tags_only_parent() -> None: + """Transitive closure: reply-to-reply often tags only the parent id, not the root.""" + sk = _sk() + root = build_signed_event(sk, created_at=1, kind=1, tags=[], content="root") + rid = root["id"] + direct = build_signed_event( + sk, + created_at=2, + kind=1, + tags=[["e", rid, "", "root"]], + content="direct", + ) + did = direct["id"] + nested = build_signed_event( + sk, + created_at=3, + kind=1, + tags=[["e", did, "", "reply"]], + content="nested", + ) + with tempfile.TemporaryDirectory() as td: + db = Database(Path(td) / "t3.sqlite") + db.connect() + db.upsert_event(root) + db.upsert_event(direct) + db.upsert_event(nested) + got = db.list_replies_to(root, limit=20) + ids = {str(x["id"]) for x in got} + assert ids == {did, nested["id"]} + + def test_list_replies_to_matches_uppercase_e() -> None: sk = _sk() root = build_signed_event(sk, created_at=1, kind=1, tags=[], content="root")