Browse Source

show the profile pic for the logged-in user

master
Silberengel 2 weeks ago
parent
commit
2fe2f99515
  1. 5
      src/imwald/core/author_html.py
  2. 42
      src/imwald/core/database.py
  3. 11
      src/imwald/core/kind0_profile.py
  4. 57
      src/imwald/ui/feed_page.py
  5. 124
      src/imwald/ui/main_window.py
  6. 31
      tests/test_thread_links.py

5
src/imwald/core/author_html.py

@ -4,7 +4,7 @@ from __future__ import annotations @@ -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( @@ -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( @@ -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 (
'<div style="display:flex;align-items:center;gap:10px;margin:0 0 6px 0">'

42
src/imwald/core/database.py

@ -568,32 +568,50 @@ class Database: @@ -568,32 +568,50 @@ 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))
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})
)
ORDER BY e.created_at ASC, e.id ASC
LIMIT ?
""",
(*THREAD_REPLY_KINDS, *THREAD_LINK_TAG_NAMES, *targets, limit),
(*THREAD_REPLY_KINDS, root_id, *THREAD_LINK_TAG_NAMES, *lt),
)
return [
{
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"],
@ -602,8 +620,12 @@ class Database: @@ -602,8 +620,12 @@ class Database:
"sig": r["sig"],
"tags": cast(list[list[str]], json.loads(r["tags_json"] or "[]")),
}
for r in cur
]
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}%"

11
src/imwald/core/kind0_profile.py

@ -42,3 +42,14 @@ def parse_kind0_profile(content: str) -> dict[str, str | None]: @@ -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"

57
src/imwald/ui/feed_page.py

@ -25,7 +25,7 @@ from PySide6.QtWidgets import ( @@ -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): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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(
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
@ -508,12 +516,51 @@ class FeedPage(QWidget): @@ -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(
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body style=\"margin:0;padding:0\">"
f"{row_html}</body></html>"
)
def _prev(self) -> None:
if self._queue:
self._index = (self._index - 1) % len(self._queue)

124
src/imwald/ui/main_window.py

@ -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()

31
tests/test_thread_links.py

@ -48,6 +48,37 @@ def test_list_replies_to_matches_q_tag() -> None: @@ -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")

Loading…
Cancel
Save