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")