|
|
|
|
@ -7,29 +7,109 @@ import json
@@ -7,29 +7,109 @@ import json
|
|
|
|
|
from typing import Any |
|
|
|
|
|
|
|
|
|
from PySide6.QtCore import Qt |
|
|
|
|
from PySide6.QtGui import QFont, QTextOption |
|
|
|
|
from PySide6.QtWidgets import ( |
|
|
|
|
QFrame, |
|
|
|
|
QHBoxLayout, |
|
|
|
|
QLabel, |
|
|
|
|
QListWidget, |
|
|
|
|
QListWidgetItem, |
|
|
|
|
QPlainTextEdit, |
|
|
|
|
QPushButton, |
|
|
|
|
QScrollArea, |
|
|
|
|
QSizePolicy, |
|
|
|
|
QSplitter, |
|
|
|
|
QTextBrowser, |
|
|
|
|
QVBoxLayout, |
|
|
|
|
QWidget, |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
from imwald.core.database import Database |
|
|
|
|
from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary |
|
|
|
|
from imwald.core.database import Database, THREAD_REPLY_KINDS |
|
|
|
|
from imwald.core.kind0_profile import display_name_from_profile, parse_kind0_profile |
|
|
|
|
from imwald.core.md_render import markdown_html_fragment, markdown_to_plain_text |
|
|
|
|
from imwald.core.nip19 import encode_npub |
|
|
|
|
from imwald.core.nostr_engine import NostrEngine |
|
|
|
|
from imwald.core.ranker import Ranker |
|
|
|
|
from imwald.ui.note_text_browser import NoteTextBrowser |
|
|
|
|
|
|
|
|
|
FEED_KINDS = (1, 20, 21, 30023, 9802, 11) |
|
|
|
|
|
|
|
|
|
_FEED_DOC_CSS = """ |
|
|
|
|
<style> |
|
|
|
|
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 15px; |
|
|
|
|
margin: 0; padding: 0; line-height: 1.5; color: #1e1b16; background: transparent; } |
|
|
|
|
a { color: #2563eb; } |
|
|
|
|
pre, code { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 13px; } |
|
|
|
|
pre { background: #f0ebe3; padding: 10px; border-radius: 8px; overflow-x: auto; } |
|
|
|
|
blockquote { border-left: 3px solid #c4b8a8; margin: 8px 0; padding: 4px 0 4px 12px; color: #4a4236; } |
|
|
|
|
.md img { max-width: 100%; height: auto; border-radius: 8px; margin: 6px 0; } |
|
|
|
|
.md p { margin: 0.45em 0; } |
|
|
|
|
</style> |
|
|
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_http_url(u: str | None) -> str | None: |
|
|
|
|
if not u or not isinstance(u, str): |
|
|
|
|
return None |
|
|
|
|
u = u.strip() |
|
|
|
|
if u.startswith("https://") or u.startswith("http://"): |
|
|
|
|
return html.escape(u, quote=True) |
|
|
|
|
return None |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _format_engagement_html(stats: dict[str, Any]) -> str: |
|
|
|
|
parts: list[str] = [] |
|
|
|
|
z = int(stats.get("zaps") or 0) |
|
|
|
|
b = int(stats.get("boosts") or 0) |
|
|
|
|
q = int(stats.get("quotes") or 0) |
|
|
|
|
rep = int(stats.get("replies") or 0) |
|
|
|
|
rtot = int(stats.get("reactions") or 0) |
|
|
|
|
if z: |
|
|
|
|
parts.append(f"⚡ <b>{z}</b>") |
|
|
|
|
if rtot: |
|
|
|
|
parts.append(f"👍 <b>{rtot}</b>") |
|
|
|
|
if b: |
|
|
|
|
parts.append(f"🔁 <b>{b}</b>") |
|
|
|
|
if q: |
|
|
|
|
parts.append(f"💬 <b>{q}</b>") |
|
|
|
|
if rep: |
|
|
|
|
parts.append(f"↩ <b>{rep}</b>") |
|
|
|
|
rx = stats.get("reaction_breakdown") or [] |
|
|
|
|
emoji_bits: list[str] = [] |
|
|
|
|
for em, c in rx[:18]: |
|
|
|
|
e = html.escape(em if em != "+" else "❤", quote=False) |
|
|
|
|
if c > 1: |
|
|
|
|
emoji_bits.append(f'<span style="font-size:18px" title="{e}×{c}">{e}<sub style="font-size:11px">{c}</sub></span>') |
|
|
|
|
else: |
|
|
|
|
emoji_bits.append(f'<span style="font-size:18px">{e}</span>') |
|
|
|
|
em_row = " ".join(emoji_bits) if emoji_bits else "" |
|
|
|
|
head = " · ".join(parts) if parts else "no engagement in local DB yet" |
|
|
|
|
if em_row: |
|
|
|
|
return f"<div style='margin-bottom:6px'>{head}</div><div>{em_row}</div>" |
|
|
|
|
return f"<div>{head}</div>" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FeedPage(QWidget): |
|
|
|
|
def __init__(self, db: Database, engine: NostrEngine, parent=None) -> None: |
|
|
|
|
super().__init__(parent) |
|
|
|
|
self.setObjectName("FeedPage") |
|
|
|
|
self.setStyleSheet( |
|
|
|
|
""" |
|
|
|
|
QWidget#FeedPage { background: #ebe6dc; } |
|
|
|
|
QFrame#EngagementBar { |
|
|
|
|
background: #faf7f2; border: 1px solid #d9d0c3; border-radius: 10px; |
|
|
|
|
padding: 8px 12px; margin-bottom: 8px; |
|
|
|
|
} |
|
|
|
|
QFrame#OpCard { |
|
|
|
|
background: #fffcf7; border: 1px solid #d9d0c3; border-radius: 12px; |
|
|
|
|
} |
|
|
|
|
QScrollArea#ThreadScroll { border: 1px solid #d9d0c3; border-radius: 10px; background: #faf7f2; } |
|
|
|
|
QFrame#ReplyCard { |
|
|
|
|
background: #fffcf7; border: 1px solid #e5ddd0; border-radius: 8px; margin: 2px 0; |
|
|
|
|
} |
|
|
|
|
QLabel#ThreadTitle { font-weight: 600; color: #3d3428; padding: 4px 2px; } |
|
|
|
|
QPlainTextEdit#ReplyBody { |
|
|
|
|
border: none; background: transparent; font-size: 14px; color: #2a241c; |
|
|
|
|
} |
|
|
|
|
""" |
|
|
|
|
) |
|
|
|
|
self._db = db |
|
|
|
|
self._engine = engine |
|
|
|
|
self._ranker = Ranker(db) |
|
|
|
|
@ -39,10 +119,44 @@ class FeedPage(QWidget):
@@ -39,10 +119,44 @@ class FeedPage(QWidget):
|
|
|
|
|
self._following: set[str] = set() |
|
|
|
|
self._list30000_pubkeys: set[str] = set() |
|
|
|
|
|
|
|
|
|
self._op = QTextBrowser() |
|
|
|
|
self._engagement = QFrame() |
|
|
|
|
self._engagement.setObjectName("EngagementBar") |
|
|
|
|
eng_layout = QVBoxLayout(self._engagement) |
|
|
|
|
eng_layout.setContentsMargins(0, 0, 0, 0) |
|
|
|
|
self._engagement_label = QLabel("") |
|
|
|
|
self._engagement_label.setTextFormat(Qt.TextFormat.RichText) |
|
|
|
|
self._engagement_label.setWordWrap(True) |
|
|
|
|
eng_layout.addWidget(self._engagement_label) |
|
|
|
|
|
|
|
|
|
self._op_card = QFrame() |
|
|
|
|
self._op_card.setObjectName("OpCard") |
|
|
|
|
op_card_lay = QVBoxLayout(self._op_card) |
|
|
|
|
op_card_lay.setContentsMargins(10, 10, 10, 10) |
|
|
|
|
self._op = NoteTextBrowser() |
|
|
|
|
self._op.setObjectName("OpNote") |
|
|
|
|
self._op.setOpenExternalLinks(True) |
|
|
|
|
self._op.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) |
|
|
|
|
self._op.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) |
|
|
|
|
op_card_lay.addWidget(self._op, stretch=1) |
|
|
|
|
|
|
|
|
|
self._why = QLabel("") |
|
|
|
|
self._thread = QListWidget() |
|
|
|
|
self._why.setStyleSheet("color: #6b5f4f; font-size: 12px;") |
|
|
|
|
self._why.setWordWrap(True) |
|
|
|
|
|
|
|
|
|
self._thread_title = QLabel( |
|
|
|
|
f"Thread (kinds {', '.join(str(k) for k in THREAD_REPLY_KINDS)})" |
|
|
|
|
) |
|
|
|
|
self._thread_title.setObjectName("ThreadTitle") |
|
|
|
|
self._thread_host = QWidget() |
|
|
|
|
self._thread_layout = QVBoxLayout(self._thread_host) |
|
|
|
|
self._thread_layout.setContentsMargins(6, 2, 6, 8) |
|
|
|
|
self._thread_layout.setSpacing(6) |
|
|
|
|
self._thread_scroll = QScrollArea() |
|
|
|
|
self._thread_scroll.setObjectName("ThreadScroll") |
|
|
|
|
self._thread_scroll.setWidgetResizable(True) |
|
|
|
|
self._thread_scroll.setWidget(self._thread_host) |
|
|
|
|
self._thread_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) |
|
|
|
|
|
|
|
|
|
prev = QPushButton("◀ Previous") |
|
|
|
|
next_ = QPushButton("Next ▶") |
|
|
|
|
prev.clicked.connect(self._prev) |
|
|
|
|
@ -61,18 +175,29 @@ class FeedPage(QWidget):
@@ -61,18 +175,29 @@ class FeedPage(QWidget):
|
|
|
|
|
nav.addWidget(self._why) |
|
|
|
|
|
|
|
|
|
left = QVBoxLayout() |
|
|
|
|
left.addWidget(self._op, stretch=1) |
|
|
|
|
left.setSpacing(8) |
|
|
|
|
left.addWidget(self._engagement) |
|
|
|
|
left.addWidget(self._op_card, stretch=1) |
|
|
|
|
left.addLayout(nav) |
|
|
|
|
|
|
|
|
|
lw = QWidget() |
|
|
|
|
lw.setLayout(left) |
|
|
|
|
right = QVBoxLayout() |
|
|
|
|
right.setSpacing(4) |
|
|
|
|
right.addWidget(self._thread_title) |
|
|
|
|
right.addWidget(self._thread_scroll, stretch=1) |
|
|
|
|
|
|
|
|
|
rw = QWidget() |
|
|
|
|
rw.setLayout(right) |
|
|
|
|
split = QSplitter(Qt.Orientation.Horizontal) |
|
|
|
|
split.addWidget(lw) |
|
|
|
|
split.addWidget(self._thread) |
|
|
|
|
split.addWidget(rw) |
|
|
|
|
split.setStretchFactor(0, 3) |
|
|
|
|
split.setStretchFactor(1, 1) |
|
|
|
|
split.setStretchFactor(1, 2) |
|
|
|
|
split.setSizes([780, 420]) |
|
|
|
|
|
|
|
|
|
outer = QVBoxLayout(self) |
|
|
|
|
outer.setContentsMargins(10, 8, 10, 8) |
|
|
|
|
outer.addWidget(split) |
|
|
|
|
|
|
|
|
|
def set_context( |
|
|
|
|
@ -118,11 +243,21 @@ class FeedPage(QWidget):
@@ -118,11 +243,21 @@ class FeedPage(QWidget):
|
|
|
|
|
if not ev.get("deleted"): |
|
|
|
|
self._db.mark_feed_viewed(self._feed_viewer_key(), ev["id"]) |
|
|
|
|
|
|
|
|
|
def _clear_thread_rows(self) -> None: |
|
|
|
|
while self._thread_layout.count(): |
|
|
|
|
item = self._thread_layout.takeAt(0) |
|
|
|
|
if item is None: |
|
|
|
|
break |
|
|
|
|
w = item.widget() |
|
|
|
|
if w is not None: |
|
|
|
|
w.deleteLater() |
|
|
|
|
|
|
|
|
|
def _show_current(self) -> None: |
|
|
|
|
if not self._queue: |
|
|
|
|
self._op.setPlainText("No events in local database yet — wait for relay sync.") |
|
|
|
|
self._thread.clear() |
|
|
|
|
self._clear_thread_rows() |
|
|
|
|
self._why.setText("") |
|
|
|
|
self._engagement_label.setText("") |
|
|
|
|
return |
|
|
|
|
ev = self._queue[self._index % len(self._queue)] |
|
|
|
|
if ev.get("deleted"): |
|
|
|
|
@ -131,8 +266,9 @@ class FeedPage(QWidget):
@@ -131,8 +266,9 @@ class FeedPage(QWidget):
|
|
|
|
|
f"<p><i>Marked deleted locally</i></p><pre>{raw}</pre>" |
|
|
|
|
f"<p style='color:gray'>{html.escape(ev['id'])}</p>" |
|
|
|
|
) |
|
|
|
|
self._thread.clear() |
|
|
|
|
self._clear_thread_rows() |
|
|
|
|
self._why.setText("") |
|
|
|
|
self._engagement_label.setText("") |
|
|
|
|
return |
|
|
|
|
score, why = self._ranker.score_event( |
|
|
|
|
ev, |
|
|
|
|
@ -140,31 +276,94 @@ class FeedPage(QWidget):
@@ -140,31 +276,94 @@ class FeedPage(QWidget):
|
|
|
|
|
following=self._following, |
|
|
|
|
list30000_pubkeys=self._list30000_pubkeys, |
|
|
|
|
) |
|
|
|
|
self._why.setText(f"score={score:.2f} {json.dumps(why, ensure_ascii=False)[:120]}…") |
|
|
|
|
self._why.setText(f"score={score:.2f}") |
|
|
|
|
self._why.setToolTip(json.dumps(why, ensure_ascii=False, indent=2)) |
|
|
|
|
|
|
|
|
|
stats = self._db.event_engagement_stats(ev["id"]) |
|
|
|
|
self._engagement_label.setText(_format_engagement_html(stats)) |
|
|
|
|
|
|
|
|
|
pk = ev["pubkey"] |
|
|
|
|
prof_row = self._db.get_latest_kind0_profile(pk) |
|
|
|
|
parsed = parse_kind0_profile((prof_row or {}).get("content") or "") |
|
|
|
|
disp = html.escape(display_name_from_profile(parsed)) |
|
|
|
|
npub = encode_npub(pk) |
|
|
|
|
npub_e = html.escape(npub) |
|
|
|
|
pk_short = html.escape(pk[:12] + "…") |
|
|
|
|
pic_url = _safe_http_url(parsed.get("picture")) |
|
|
|
|
nip05 = html.escape((parsed.get("nip05") or "").strip()) if parsed.get("nip05") else "" |
|
|
|
|
about = html.escape((parsed.get("about") or "")[:280]) if parsed.get("about") else "" |
|
|
|
|
avatar_html = ( |
|
|
|
|
f'<img src="{pic_url}" width="52" height="52" style="border-radius:10px;object-fit:cover;vertical-align:middle;margin-right:10px"/>' |
|
|
|
|
if pic_url |
|
|
|
|
else '<span style="display:inline-block;width:52px;height:52px;border-radius:10px;background:#d9d0c3;margin-right:10px;vertical-align:middle"></span>' |
|
|
|
|
) |
|
|
|
|
nip_line = f"<div style='color:#6b5f4f;font-size:13px;margin-top:4px'>{nip05}</div>" if nip05 else "" |
|
|
|
|
about_line = f"<div style='color:#5c5246;font-size:13px;margin-top:6px'>{about}</div>" if about else "" |
|
|
|
|
|
|
|
|
|
tr = "" |
|
|
|
|
sr = ev.get("source_relay") or "" |
|
|
|
|
if sr and "nostrarchives.com" in sr: |
|
|
|
|
tr = "<p><i>Trending slice (nostrarchives)</i></p>" |
|
|
|
|
pk = html.escape(ev["pubkey"][:16] + "…") |
|
|
|
|
tr = "<div style='color:#7a6b55;font-size:13px;margin:6px 0'><i>Trending slice (nostrarchives)</i></div>" |
|
|
|
|
|
|
|
|
|
eid = html.escape(ev["id"]) |
|
|
|
|
md_body = markdown_html_fragment(ev.get("content") or "") |
|
|
|
|
body = ( |
|
|
|
|
f"<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>" |
|
|
|
|
f"<h2>Kind {int(ev['kind'])}</h2>" |
|
|
|
|
f"<p><b>{pk}</b> · {int(ev['created_at'])}</p>" |
|
|
|
|
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">" |
|
|
|
|
f"{_FEED_DOC_CSS}</head><body>" |
|
|
|
|
f"<div style='display:flex;align-items:flex-start;margin-bottom:12px'>" |
|
|
|
|
f"{avatar_html}" |
|
|
|
|
f"<div style='flex:1'><div style='font-size:18px;font-weight:600'>{disp}</div>" |
|
|
|
|
f"<div style='color:#6b5f4f;font-size:13px'>{npub_e} · {pk_short}</div>" |
|
|
|
|
f"{nip_line}{about_line}</div></div>" |
|
|
|
|
f"<div style='color:#7a6b55;font-size:13px;margin-bottom:8px'>Kind {int(ev['kind'])} · {int(ev['created_at'])}</div>" |
|
|
|
|
f"{tr}" |
|
|
|
|
f"<div class=\"md\">{md_body}</div>" |
|
|
|
|
f"<p style='color:gray'>{eid}</p>" |
|
|
|
|
f"</body></html>" |
|
|
|
|
f"<p style='color:#9a8b78;font-size:12px;margin-top:14px'>{eid}</p>" |
|
|
|
|
"</body></html>" |
|
|
|
|
) |
|
|
|
|
self._op.setHtml(body) |
|
|
|
|
self._thread.clear() |
|
|
|
|
for r in self._db.list_replies_to(ev["id"]): |
|
|
|
|
snippet = markdown_plain_summary(r.get("content") or "", max_len=90) |
|
|
|
|
line = f"k{r['kind']} {r['pubkey'][:8]}… — {snippet}" |
|
|
|
|
it = QListWidgetItem(line) |
|
|
|
|
it.setData(Qt.ItemDataRole.UserRole, r["id"]) |
|
|
|
|
self._thread.addItem(it) |
|
|
|
|
|
|
|
|
|
self._clear_thread_rows() |
|
|
|
|
replies = self._db.list_replies_to(ev["id"]) |
|
|
|
|
pubkeys = [str(r["pubkey"]) for r in replies] |
|
|
|
|
profiles = self._db.get_latest_kind0_profiles(pubkeys) |
|
|
|
|
for r in replies: |
|
|
|
|
rpk = str(r["pubkey"]).lower() |
|
|
|
|
pr = profiles.get(rpk) or {} |
|
|
|
|
rp = parse_kind0_profile(pr.get("content") or "") |
|
|
|
|
rname = html.escape(display_name_from_profile(rp)) |
|
|
|
|
rnpub = html.escape(encode_npub(rpk)) |
|
|
|
|
plain = markdown_to_plain_text(r.get("content") or "") |
|
|
|
|
|
|
|
|
|
card = QFrame() |
|
|
|
|
card.setObjectName("ReplyCard") |
|
|
|
|
vl = QVBoxLayout(card) |
|
|
|
|
vl.setContentsMargins(8, 6, 8, 8) |
|
|
|
|
rk = int(r["kind"]) |
|
|
|
|
head = QLabel( |
|
|
|
|
f"<span style='color:#8a7b6a;font-size:11px'>k{rk}</span> " |
|
|
|
|
f"<b>{rname}</b> <span style='color:#7a6b55;font-size:12px'>{rnpub}</span>" |
|
|
|
|
) |
|
|
|
|
head.setTextFormat(Qt.TextFormat.RichText) |
|
|
|
|
head.setWordWrap(True) |
|
|
|
|
f_small = QFont() |
|
|
|
|
f_small.setPointSize(11) |
|
|
|
|
head.setFont(f_small) |
|
|
|
|
body_te = QPlainTextEdit() |
|
|
|
|
body_te.setObjectName("ReplyBody") |
|
|
|
|
body_te.setReadOnly(True) |
|
|
|
|
body_te.setPlainText(plain or "(empty)") |
|
|
|
|
body_te.setWordWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) |
|
|
|
|
body_te.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) |
|
|
|
|
body_te.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) |
|
|
|
|
body_te.setFrameShape(QFrame.Shape.NoFrame) |
|
|
|
|
body_te.document().setDocumentMargin(0) |
|
|
|
|
body_te.setMinimumHeight(96) |
|
|
|
|
body_te.setMaximumHeight(440) |
|
|
|
|
vl.addWidget(head) |
|
|
|
|
vl.addWidget(body_te) |
|
|
|
|
self._thread_layout.addWidget(card) |
|
|
|
|
self._thread_layout.addStretch(1) |
|
|
|
|
|
|
|
|
|
def _prev(self) -> None: |
|
|
|
|
if self._queue: |
|
|
|
|
|