Browse Source

bug-fixes

master
Silberengel 2 weeks ago
parent
commit
5949fba143
  1. 96
      src/imwald/core/database.py
  2. 36
      src/imwald/ui/feed_page.py
  3. 231
      src/imwald/ui/profile_page.py
  4. 31
      tests/test_feed_views.py
  5. 76
      tests/test_profile_interactions.py

96
src/imwald/core/database.py

@ -57,6 +57,20 @@ THREAD_REPLY_KINDS: tuple[int, ...] = (
9735, # zap receipt 9735, # zap receipt
) )
# Profile “activity” column: kinds that often reference someone else’s event via thread tags.
PROFILE_OUTGOING_INTERACTION_KINDS: tuple[int, ...] = (
1,
6,
7,
16,
20,
21,
42,
1111,
9802,
30023,
)
def _is_addressable_kind(kind: int) -> bool: def _is_addressable_kind(kind: int) -> bool:
return 30000 <= kind < 40000 return 30000 <= kind < 40000
@ -510,6 +524,82 @@ class Database:
) )
return rows return rows
def list_pubkey_outgoing_interactions(
self,
pubkey: str,
*,
limit: int = 48,
) -> list[dict[str, Any]]:
"""
Recent events by ``pubkey`` that link (``e`` / ``E`` / ``q``) to another authors event.
Used for a profile replies & interactions column (exclude self-replies and orphan links).
"""
pk = pubkey.strip().lower()
if len(pk) != 64 or any(c not in "0123456789abcdef" for c in pk):
return []
kind_ph = ",".join("?" * len(PROFILE_OUTGOING_INTERACTION_KINDS))
tag_ph = ",".join("?" * len(THREAD_LINK_TAG_NAMES))
sql = f"""
SELECT e.id, e.pubkey, e.created_at, e.kind, e.content, e.sig, e.tags_json,
(
SELECT p.id
FROM tags t
INNER JOIN events p ON lower(p.id) = lower(t.value) AND p.deleted = 0
WHERE t.event_id = e.id
AND t.name IN ({tag_ph})
AND length(t.value) = 64
AND lower(p.pubkey) != lower(e.pubkey)
ORDER BY t.pos
LIMIT 1
) AS target_event_id
FROM events e
WHERE e.deleted = 0 AND lower(e.pubkey) = lower(?)
AND e.kind IN ({kind_ph})
AND EXISTS (
SELECT 1 FROM tags t2
INNER JOIN events p2 ON lower(p2.id) = lower(t2.value) AND p2.deleted = 0
WHERE t2.event_id = e.id
AND t2.name IN ({tag_ph})
AND length(t2.value) = 64
AND lower(p2.pubkey) != lower(e.pubkey)
)
ORDER BY e.created_at DESC, e.id DESC
LIMIT ?
"""
cur = self.conn().execute(
sql,
(*THREAD_LINK_TAG_NAMES, pk, *PROFILE_OUTGOING_INTERACTION_KINDS, *THREAD_LINK_TAG_NAMES, limit),
)
rows: list[dict[str, Any]] = []
for r in cur:
tid = r["target_event_id"]
rows.append(
{
"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 "[]")),
"target_event_id": str(tid) if tid else None,
}
)
need = {str(x["target_event_id"]).lower() for x in rows if x.get("target_event_id")}
pub_by_eid: dict[str, str] = {}
if need:
ph = ",".join("?" * len(need))
for er in self.conn().execute(
f"SELECT id, pubkey FROM events WHERE lower(id) IN ({ph})",
tuple(sorted(need)),
):
pub_by_eid[str(er["id"]).lower()] = str(er["pubkey"])
for x in rows:
te = x.get("target_event_id")
x["target_pubkey"] = pub_by_eid.get(str(te).lower(), "") if te else ""
return rows
def get_event(self, event_id: str) -> StoredEventRow | None: def get_event(self, event_id: str) -> StoredEventRow | None:
cur = self.conn().execute( cur = self.conn().execute(
"SELECT id,pubkey,created_at,kind,content,sig,tags_json,deleted,source_relay FROM events WHERE id=?", "SELECT id,pubkey,created_at,kind,content,sig,tags_json,deleted,source_relay FROM events WHERE id=?",
@ -567,6 +657,10 @@ class Database:
viewer_pubkey: str | None = None, viewer_pubkey: str | None = None,
exclude_viewed: bool = True, exclude_viewed: bool = True,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""
Feed-shaped rows newest-first. With ``exclude_viewed`` and ``viewer_pubkey``,
omits ids already in ``feed_views`` (the UI records an OP when it is shown).
"""
kind_list = list(kinds) kind_list = list(kinds)
placeholders = ",".join("?" * len(kind_list)) placeholders = ",".join("?" * len(kind_list))
sql = f""" sql = f"""
@ -601,7 +695,7 @@ class Database:
return out return out
def mark_feed_viewed(self, viewer_pubkey: str, event_id: str) -> None: def mark_feed_viewed(self, viewer_pubkey: str, event_id: str) -> None:
"""Remember this event was shown in the feed so we do not surface it again.""" """Persist that this viewer was shown this OP (pager); used to prefer fresh notes in the feed."""
with self.write_lock() as c: with self.write_lock() as c:
c.execute( c.execute(
""" """

36
src/imwald/ui/feed_page.py

@ -350,17 +350,29 @@ class FeedPage(QWidget):
"""Per-device feed history; logged-out users share `_anon`.""" """Per-device feed history; logged-out users share `_anon`."""
return (self._my_pubkey or "_anon").lower() return (self._my_pubkey or "_anon").lower()
def reload_queue(self) -> None: def _ranked_feed_candidates(self) -> list[dict[str, Any]]:
"""Prefer unseen notes; if everything in the local slice is already seen, fall back to any notes."""
hide = self._db.get_setting("hide_nsfw", "1") == "1" hide = self._db.get_setting("hide_nsfw", "1") == "1"
vk = self._feed_viewer_key()
raw = self._db.feed_candidates( raw = self._db.feed_candidates(
FEED_KINDS, FEED_KINDS,
hide_nsfw=hide, hide_nsfw=hide,
limit=500, limit=500,
viewer_pubkey=self._feed_viewer_key(), viewer_pubkey=vk,
exclude_viewed=True, exclude_viewed=True,
) )
ranked = self._ranker.rank_feed(raw, self._my_pubkey, self._following, self._list30000_pubkeys) if not raw:
self._queue = ranked raw = self._db.feed_candidates(
FEED_KINDS,
hide_nsfw=hide,
limit=500,
viewer_pubkey=vk,
exclude_viewed=False,
)
return self._ranker.rank_feed(raw, self._my_pubkey, self._following, self._list30000_pubkeys)
def reload_queue(self) -> None:
self._queue = self._ranked_feed_candidates()
self._index = 0 self._index = 0
self._show_current() self._show_current()
@ -375,15 +387,7 @@ class FeedPage(QWidget):
self._queue = [cast(dict[str, Any], ev)] self._queue = [cast(dict[str, Any], ev)]
self._show_current() self._show_current()
return return
hide = self._db.get_setting("hide_nsfw", "1") == "1" ranked = self._ranked_feed_candidates()
raw = self._db.feed_candidates(
FEED_KINDS,
hide_nsfw=hide,
limit=500,
viewer_pubkey=self._feed_viewer_key(),
exclude_viewed=True,
)
ranked = self._ranker.rank_feed(raw, self._my_pubkey, self._following, self._list30000_pubkeys)
self._queue = ranked self._queue = ranked
found = False found = False
for i, ev in enumerate(self._queue): for i, ev in enumerate(self._queue):
@ -404,8 +408,6 @@ class FeedPage(QWidget):
self._queue = [cast(dict[str, Any], ev)] self._queue = [cast(dict[str, Any], ev)]
self._index = 0 self._index = 0
self._show_current() self._show_current()
if not ev.get("deleted"):
self._db.mark_feed_viewed(self._feed_viewer_key(), ev["id"])
def _build_op_html(self, ev: dict[str, Any], nip05_verified: list[tuple[str, bool]] | None) -> str: def _build_op_html(self, ev: dict[str, Any], nip05_verified: list[tuple[str, bool]] | None) -> str:
"""Full OP ``QTextBrowser`` document: compact author row + note body.""" """Full OP ``QTextBrowser`` document: compact author row + note body."""
@ -545,6 +547,7 @@ class FeedPage(QWidget):
self._op_ev_snapshot = ev self._op_ev_snapshot = ev
body = self._build_op_html(ev, None) body = self._build_op_html(ev, None)
self._op.setHtml(body) self._op.setHtml(body)
self._db.mark_feed_viewed(self._feed_viewer_key(), root_id)
pk = op_pk pk = op_pk
prof_row2 = self._db.get_latest_kind0_profile(pk) prof_row2 = self._db.get_latest_kind0_profile(pk)
@ -670,9 +673,6 @@ class FeedPage(QWidget):
def _next(self) -> None: def _next(self) -> None:
if not self._queue: if not self._queue:
return return
cur = self._queue[self._index % len(self._queue)]
if not cur.get("deleted"):
self._db.mark_feed_viewed(self._feed_viewer_key(), cur["id"])
self._index = (self._index + 1) % len(self._queue) self._index = (self._index + 1) % len(self._queue)
self._show_current() self._show_current()

231
src/imwald/ui/profile_page.py

@ -28,6 +28,19 @@ from imwald.ui.theme import ACCENT_SOFT, BG_CARD, BG_CODE, BORDER, FEED_DOC_CSS,
# Notes to list under “Recent in local DB” (feed-shaped kinds). # Notes to list under “Recent in local DB” (feed-shaped kinds).
_PROFILE_NOTE_KINDS: tuple[int, ...] = (1, 6, 20, 21, 30023, 9802, 11) _PROFILE_NOTE_KINDS: tuple[int, ...] = (1, 6, 20, 21, 30023, 9802, 11)
_ACTIVITY_KIND_LABEL: dict[int, str] = {
1: "Reply",
6: "Repost",
7: "Reaction",
16: "Repost",
20: "Channel",
21: "Channel msg",
42: "File",
1111: "Comment",
9802: "Highlight",
30023: "Article",
}
PROFILE_HERO_BANNER_H = 240 PROFILE_HERO_BANNER_H = 240
PROFILE_HERO_AVATAR = 136 PROFILE_HERO_AVATAR = 136
PROFILE_HERO_OVERLAP = 0.48 # fraction of avatar height drawn above the banner bottom edge PROFILE_HERO_OVERLAP = 0.48 # fraction of avatar height drawn above the banner bottom edge
@ -307,7 +320,7 @@ class _ProfileLnurlRunnable(QRunnable):
class ProfilePage(QWidget): class ProfilePage(QWidget):
"""One pubkey: metadata, NIP-65 relays, follows (kind 3), emoji inventory, raw JSON, recent notes.""" """One pubkey: compact profile column, posts, and outgoing activity (replies/reactions to others)."""
open_note_new_tab = Signal(str) open_note_new_tab = Signal(str)
open_profile = Signal(str) open_profile = Signal(str)
@ -338,6 +351,13 @@ class ProfilePage(QWidget):
self._feed_body.setOpenExternalLinks(False) self._feed_body.setOpenExternalLinks(False)
self._feed_body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self._feed_body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._feed_body.anchorClicked.connect(self._dispatch_profile_anchor) self._feed_body.anchorClicked.connect(self._dispatch_profile_anchor)
self._interact_body = NoteTextBrowser()
self._interact_body.setObjectName("ProfileBodyInteract")
self._interact_body.setOpenLinks(False)
self._interact_body.setOpenExternalLinks(False)
self._interact_body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._interact_body.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._interact_body.anchorClicked.connect(self._dispatch_profile_anchor)
self._left_column = QWidget(self) self._left_column = QWidget(self)
left_lay = QVBoxLayout(self._left_column) left_lay = QVBoxLayout(self._left_column)
left_lay.setContentsMargins(0, 0, 0, 0) left_lay.setContentsMargins(0, 0, 0, 0)
@ -349,12 +369,15 @@ class ProfilePage(QWidget):
split.setChildrenCollapsible(False) split.setChildrenCollapsible(False)
split.addWidget(self._left_column) split.addWidget(self._left_column)
split.addWidget(self._feed_body) split.addWidget(self._feed_body)
# Narrower metadata column, wider posts — inverse of FeedPage (thread wide, OP narrower). split.addWidget(self._interact_body)
# Metadata (narrow) · posts (widest) · activity (medium).
split.setStretchFactor(0, 2) split.setStretchFactor(0, 2)
split.setStretchFactor(1, 3) split.setStretchFactor(1, 4)
split.setSizes([400, 780]) split.setStretchFactor(2, 2)
self._left_column.setMinimumWidth(300) split.setSizes([300, 640, 280])
self._feed_body.setMinimumWidth(260) self._left_column.setMinimumWidth(260)
self._feed_body.setMinimumWidth(240)
self._interact_body.setMinimumWidth(220)
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0) lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(split) lay.addWidget(split)
@ -374,6 +397,9 @@ class ProfilePage(QWidget):
# resetting placeholders + bumping fetch generations on every refresh() while ingest runs. # resetting placeholders + bumping fetch generations on every refresh() while ingest runs.
self._cover_banner_http_key: str | None = None self._cover_banner_http_key: str | None = None
self._cover_avatar_http_key: str | None = None self._cover_avatar_http_key: str | None = None
self._left_panel_sig: object | None = None
self._feed_panel_sig: object | None = None
self._interact_panel_sig: object | None = None
self.refresh() self.refresh()
def tab_title(self) -> str: def tab_title(self) -> str:
@ -416,18 +442,6 @@ class ProfilePage(QWidget):
lud06_s = lud06_raw.strip() if isinstance(lud06_raw, str) else "" lud06_s = lud06_raw.strip() if isinstance(lud06_raw, str) else ""
lud16_s = lud16_raw.strip() if isinstance(lud16_raw, str) else "" lud16_s = lud16_raw.strip() if isinstance(lud16_raw, str) else ""
lnurls = collect_unique_lnurlp_urls(lud06_s or None, lud16_s or None) lnurls = collect_unique_lnurlp_urls(lud06_s or None, lud16_s or None)
pay_rows: list[str] = []
if lud06_s:
pay_rows.append(
f"<p style='margin:6px 0;color:{TEXT_DIM}'><b>lud06</b> (LNURL / NIP-57): "
f"<code style='color:{TEXT_MUTED}'>{html.escape(lud06_s[:200])}</code></p>"
)
if lud16_s:
pay_rows.append(
f"<p style='margin:6px 0;color:{TEXT_DIM}'><b>lud16</b> (Lightning address or HTTPS LNURL): "
f"<code style='color:{TEXT_MUTED}'>{html.escape(lud16_s[:200])}</code></p>"
)
pay_static = "".join(pay_rows)
live_lnurl = "" live_lnurl = ""
if from_lnurl and lnurl_html is not None: if from_lnurl and lnurl_html is not None:
live_lnurl = lnurl_html live_lnurl = lnurl_html
@ -435,10 +449,43 @@ class ProfilePage(QWidget):
self._lnurl_gen += 1 self._lnurl_gen += 1
gen = self._lnurl_gen gen = self._lnurl_gen
self._lnurl_pool.start(_ProfileLnurlRunnable(lnurls, gen, self._lnurl_sigs)) self._lnurl_pool.start(_ProfileLnurlRunnable(lnurls, gen, self._lnurl_sigs))
live_lnurl = f"<p style='color:{TEXT_DIM}'><i>Fetching LNURL-pay metadata…</i></p>" live_lnurl = f"<p style='color:{TEXT_DIM};font-size:14px;margin:8px 0 0 0'><i>Loading tip details…</i></p>"
tech_dl: list[str] = []
if lud06_s:
tech_dl.append(
f"<dt style='color:{TEXT_MUTED};margin-top:6px'>lud06</dt>"
f"<dd style='margin:0'><code style='color:{TEXT_DIM};font-size:12px;word-break:break-all'>"
f"{html.escape(lud06_s[:400])}</code></dd>"
)
if lud16_s:
tech_dl.append(
f"<dt style='color:{TEXT_MUTED};margin-top:6px'>lud16</dt>"
f"<dd style='margin:0'><code style='color:{TEXT_DIM};font-size:12px;word-break:break-all'>"
f"{html.escape(lud16_s[:400])}</code></dd>"
)
tech_block = ""
if tech_dl:
tech_block = (
f"<details style='margin-top:10px'><summary style='cursor:pointer;color:{TEXT_DIM};font-size:13px'>"
f"Raw LNURL fields</summary><dl style='margin:8px 0 0 0'>{''.join(tech_dl)}</dl></details>"
)
tips_head = ""
if lud16_s:
if "@" in lud16_s and not lud16_s.lower().startswith("http"):
tips_head = (
f"<p style='margin:0;color:{TEXT};font-size:17px;font-weight:700'>{html.escape(lud16_s)}</p>"
f"<p style='margin:4px 0 0 0;color:{TEXT_DIM};font-size:13px'>Lightning tips</p>"
)
elif lud16_s.lower().startswith("https://"):
tips_head = f"<p style='margin:0;color:{TEXT_DIM};font-size:14px'>LNURL-pay (HTTPS) on file.</p>"
elif lud06_s:
tips_head = (
f"<p style='margin:0;color:{TEXT_DIM};font-size:14px'>LNURL (bech32) on file — "
f"expand <i>Raw LNURL fields</i> if you need the decoded URL.</p>"
)
pay_card = "" pay_card = ""
if pay_static or live_lnurl: if tips_head or live_lnurl or tech_block:
pay_card = f"{_sec_title('Lightning · NIP-57')}<div>{pay_static}{live_lnurl}</div>" pay_card = f"{_sec_title('Tips')}<div>{tips_head}{live_lnurl}{tech_block}</div>"
disp_plain = display_name_from_profile_or_hex(parsed, pk) disp_plain = display_name_from_profile_or_hex(parsed, pk)
nip05_plain = (parsed.get("nip05") or "").strip() if parsed.get("nip05") else "" nip05_plain = (parsed.get("nip05") or "").strip() if parsed.get("nip05") else ""
@ -517,26 +564,29 @@ class ProfilePage(QWidget):
raw_json = content or "" raw_json = content or ""
raw_esc = html.escape(raw_json[:12000] + ("" if len(raw_json) > 12000 else ""), quote=False) raw_esc = html.escape(raw_json[:12000] + ("" if len(raw_json) > 12000 else ""), quote=False)
json_inner = ( json_inner = (
f"{_sec_title('Kind 0 JSON')}" f"<details><summary style='cursor:pointer;color:{TEXT_DIM};font-size:13px'>"
f"<pre style='color:{TEXT_DIM};font-size:13px;white-space:pre-wrap;word-break:break-all;" f"Developer · raw kind 0 JSON</summary>"
f"background:{BG_CODE};padding:12px;border-radius:8px;border:1px solid {BORDER};margin:0'>{raw_esc}</pre>" f"<pre style='color:{TEXT_DIM};font-size:12px;white-space:pre-wrap;word-break:break-all;"
f"background:{BG_CODE};padding:10px;border-radius:8px;border:1px solid {BORDER};margin:8px 0 0 0'>"
f"{raw_esc}</pre></details>"
) )
k10002 = self._db.get_latest_kind10002_event(pk) k10002 = self._db.get_latest_kind10002_event(pk)
relay_inner = ( relay_inner = (
f"<p style='color:{TEXT_DIM};font-size:15px;margin:0'>" f"<p style='color:{TEXT_DIM};font-size:14px;margin:0'>"
f"<i>No NIP-65 relay list (kind 10002) in local DB yet.</i></p>" f"<i>No relay list stored locally yet (NIP-65).</i></p>"
) )
if k10002: if k10002:
reads, writes = parse_kind10002_tags(k10002.get("tags") or []) reads, writes = parse_kind10002_tags(k10002.get("tags") or [])
r_esc = "<br>".join(html.escape(u) for u in reads[:40]) r_esc = "<br>".join(html.escape(u) for u in reads[:24])
w_esc = "<br>".join(html.escape(u) for u in writes[:40]) w_esc = "<br>".join(html.escape(u) for u in writes[:24])
relay_inner = ( relay_inner = (
f"{_sec_title('Relays · NIP-65 (kind 10002)')}" f"{_sec_title('Relays')}"
f"<p style='color:{TEXT_MUTED};font-size:14px;margin:0 0 6px 0'><b>Read</b></p>" f"<p style='color:{TEXT_MUTED};font-size:12px;margin:0 0 6px 0'>Outbox (read / write)</p>"
f"<div style='color:{TEXT_DIM};font-size:14px;line-height:1.45'>{r_esc or ''}</div>" f"<p style='color:{TEXT_MUTED};font-size:13px;margin:0 0 4px 0'><b>Read</b></p>"
f"<p style='color:{TEXT_MUTED};font-size:14px;margin:12px 0 6px 0'><b>Write</b></p>" f"<div style='color:{TEXT_DIM};font-size:13px;line-height:1.45'>{r_esc or ''}</div>"
f"<div style='color:{TEXT_DIM};font-size:14px;line-height:1.45'>{w_esc or ''}</div>" f"<p style='color:{TEXT_MUTED};font-size:13px;margin:10px 0 4px 0'><b>Write</b></p>"
f"<div style='color:{TEXT_DIM};font-size:13px;line-height:1.45'>{w_esc or ''}</div>"
) )
follows = self._db.get_latest_kind3_contact_pubkeys(pk, limit=400) follows = self._db.get_latest_kind3_contact_pubkeys(pk, limit=400)
@ -547,23 +597,29 @@ class ProfilePage(QWidget):
follow_lines.append( follow_lines.append(
f'<div style="margin:4px 0"><a href="{html.escape(href, quote=True)}" ' f'<div style="margin:4px 0"><a href="{html.escape(href, quote=True)}" '
f'style="color:{TEXT};text-decoration:none">{html.escape(np)}</a>' f'style="color:{TEXT};text-decoration:none">{html.escape(np)}</a>'
f'<span style="color:{TEXT_DIM};font-size:13px"> · {html.escape(fp[:16])}…</span></div>' f'<span style="color:{TEXT_DIM};font-size:12px"> · {html.escape(fp[:16])}…</span></div>'
)
_no_follow = f"<i style='color:{TEXT_DIM}'>No kind 3 in local DB.</i>"
follow_inner = (
f"{_sec_title('Following · kind 3 (local)')}"
f"<div style='font-size:14px;line-height:1.45'>{''.join(follow_lines) or _no_follow}</div>"
) )
_no_follow = f"<i style='color:{TEXT_DIM}'>No contact list in local DB yet.</i>"
follow_inner = f"{_sec_title('Following')}<div style='font-size:13px;line-height:1.45'>{''.join(follow_lines) or _no_follow}</div>"
nip30 = self._db.get_author_nip30_emoji_urls(pk) nip30 = self._db.get_author_nip30_emoji_urls(pk)
em_lines: list[str] = [] em_lines: list[str] = []
for short, url in sorted(nip30.items(), key=lambda x: x[0])[:48]: for short, url in sorted(nip30.items(), key=lambda x: x[0])[:48]:
em_lines.append( em_lines.append(
f"<div style='margin:3px 0'><code style='color:{TEXT_MUTED}'>:{html.escape(short)}:</code> " f"<div style='margin:3px 0'><code style='color:{TEXT_MUTED}'>:{html.escape(short)}:</code> "
f'<a href="{html.escape(url, quote=True)}" style="color:{TEXT}">{html.escape(url[:48])}…</a></div>' f'<a href="{html.escape(url, quote=True)}" style="color:{TEXT};font-size:12px">'
f"{html.escape(url[:40])}…</a></div>"
)
_no_emoji = f"<i style='color:{TEXT_DIM}'>No custom emoji indexed yet.</i>"
if em_lines:
emoji_inner = (
f"{_sec_title('Emoji')}"
f"<p style='color:{TEXT_DIM};font-size:13px;margin:0 0 6px 0'>{len(nip30)} shortcodes (local).</p>"
f"<details><summary style='cursor:pointer;color:{TEXT_DIM};font-size:13px'>Show list</summary>"
f"<div style='margin-top:8px'>{''.join(em_lines)}</div></details>"
) )
_no_emoji = f"<i style='color:{TEXT_DIM}'>No emoji packs indexed yet.</i>" else:
emoji_inner = f"{_sec_title('Custom emoji · NIP-30')}" f"<div>{''.join(em_lines) or _no_emoji}</div>" emoji_inner = f"{_sec_title('Emoji')}<div>{_no_emoji}</div>"
notes = self._db.list_events_by_pubkey(pk, kinds=_PROFILE_NOTE_KINDS, limit=40) notes = self._db.list_events_by_pubkey(pk, kinds=_PROFILE_NOTE_KINDS, limit=40)
note_lines: list[str] = [] note_lines: list[str] = []
@ -595,8 +651,8 @@ class ProfilePage(QWidget):
_no_notes = "No matching notes stored yet." _no_notes = "No matching notes stored yet."
card_style = ( card_style = (
f"background:{BG_CARD};border:1px solid {BORDER};border-radius:14px;" f"background:{BG_CARD};border:1px solid {BORDER};border-radius:12px;"
f"padding:16px 18px;margin-bottom:14px" f"padding:12px 14px;margin-bottom:12px"
) )
left_parts: list[str] = [] left_parts: list[str] = []
if about_inner: if about_inner:
@ -610,7 +666,7 @@ class ProfilePage(QWidget):
left_doc = ( left_doc = (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">" "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body style='padding:14px 16px 28px 18px'>" f"{FEED_DOC_CSS}</head><body style='padding:12px 12px 22px 14px'>"
f"{''.join(left_parts)}</body></html>" f"{''.join(left_parts)}</body></html>"
) )
@ -631,8 +687,93 @@ class ProfilePage(QWidget):
f"{FEED_DOC_CSS}</head><body style='padding:14px 14px 28px 12px'>" f"{FEED_DOC_CSS}</head><body style='padding:14px 14px 28px 12px'>"
f"{feed_intro}{feed_inner}</body></html>" f"{feed_intro}{feed_inner}</body></html>"
) )
interact = self._db.list_pubkey_outgoing_interactions(pk, limit=45)
it_pks = {str(x.get("target_pubkey") or "").lower() for x in interact if x.get("target_pubkey")}
it_profiles = self._db.get_latest_kind0_profiles(sorted(it_pks)) if it_pks else {}
act_lines: list[str] = []
for evi in interact:
eid_i = str(evi["id"])
href_self = f"imwald://note/{eid_i}"
k_i = int(evi["kind"])
k_lab = _ACTIVITY_KIND_LABEL.get(k_i, f"Kind {k_i}")
nip_i = cast(list[list[str]], evi["tags"]) if isinstance(evi.get("tags"), list) else None
snip_i = markdown_plain_summary(
evi.get("content") or "",
max_len=72,
db=self._db,
nip30_tags=nip_i,
nip30_author_pubkey=str(evi.get("pubkey") or "") or None,
)
t_hi = _fmt_event_time(evi.get("created_at"))
tpk = str(evi.get("target_pubkey") or "").lower()
row_pr = it_profiles.get(tpk)
t_nm = (
display_name_from_profile_or_hex(parse_kind0_profile(row_pr["content"]), tpk)
if row_pr
else encode_npub(tpk)[:18] + ""
)
parent_href = ""
tid = evi.get("target_event_id")
if tid:
note_h = html.escape(f"imwald://note/{str(tid).lower()}", quote=True)
pub_h = html.escape(f"imwald://pub/{tpk}", quote=True)
parent_href = (
f"<div style='margin-top:6px;font-size:12px;color:{TEXT_MUTED}'>On "
f'<a href="{note_h}" style="color:{ACCENT_SOFT};text-decoration:none">parent note</a>'
f' · <a href="{pub_h}" style="color:{ACCENT_SOFT};text-decoration:none">'
f"{html.escape(t_nm)}</a></div>"
)
esc_h = html.escape(href_self, quote=True)
act_lines.append(
f'<a href="{esc_h}" style="display:block;text-decoration:none;color:{TEXT};'
f"background:{BG_CARD};border:1px solid {BORDER};border-left:3px solid {ACCENT_SOFT};"
f'border-radius:10px;padding:10px 12px;margin-bottom:10px">'
f"<div style='color:{TEXT_MUTED};font-size:11px;font-weight:700;text-transform:uppercase'>"
f"{html.escape(k_lab)} · {html.escape(t_hi)}</div>"
f"<div style='color:{TEXT_DIM};margin-top:6px;font-size:14px;line-height:1.45'>"
f"{html.escape(snip_i)}</div>{parent_href}"
f"<div style='color:{ACCENT_SOFT};font-size:11px;margin-top:6px;font-weight:600'>"
f"Open thread →</div></a>"
)
_no_act = "No replies or reactions to other people's notes in the local database yet."
act_intro = (
f'<div style="margin-bottom:14px;padding-bottom:12px;border-bottom:1px solid {BORDER}">'
f'<div style="font-size:18px;font-weight:700;color:{TEXT};letter-spacing:-0.02em">Activity</div>'
f'<div style="color:{TEXT_DIM};font-size:13px;margin-top:6px;line-height:1.4">'
f"Replies, boosts, and reactions that <b style='color:{TEXT}'>link to someone else's</b> events "
f"(from this device’s index).</div></div>"
)
act_inner = "".join(act_lines) if act_lines else f"<p style='color:{TEXT_DIM};font-size:14px'><i>{html.escape(_no_act)}</i></p>"
interact_doc = (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body style='padding:12px 12px 24px 10px'>"
f"{act_intro}{act_inner}</body></html>"
)
left_sig = (
content,
created0,
str(k10002["id"]) if k10002 else "",
tuple(follows[:80]),
tuple(sorted(nip30.items())),
about_raw,
lud06_s,
lud16_s,
live_lnurl,
)
feed_sig = tuple((str(ev["id"]), int(ev["created_at"])) for ev in notes)
interact_sig = tuple((str(x["id"]), int(x["created_at"])) for x in interact)
if self._left_panel_sig != left_sig:
self._left_panel_sig = left_sig
self._left_body.setHtml(left_doc) self._left_body.setHtml(left_doc)
if self._feed_panel_sig != feed_sig:
self._feed_panel_sig = feed_sig
self._feed_body.setHtml(feed_doc) self._feed_body.setHtml(feed_doc)
if self._interact_panel_sig != interact_sig:
self._interact_panel_sig = interact_sig
self._interact_body.setHtml(interact_doc)
tw = self.parentWidget() tw = self.parentWidget()
if isinstance(tw, QTabWidget): if isinstance(tw, QTabWidget):
i = tw.indexOf(self) i = tw.indexOf(self)

31
tests/test_feed_views.py

@ -0,0 +1,31 @@
"""Feed candidate selection and feed_views persistence."""
import tempfile
from pathlib import Path
from typing import cast
from imwald.core.database import Database
from imwald.core.nostr_crypto import build_signed_event, pubkey_hex_from_secret
def _sk() -> bytes:
return bytes.fromhex("3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683")
def test_feed_candidates_exclude_viewed_then_include_when_all_seen() -> None:
sk = _sk()
pk = pubkey_hex_from_secret(sk)
ev = build_signed_event(sk, created_at=100, kind=1, tags=[], content="hello feed")
eid = cast(str, ev["id"])
kinds = (1,)
with tempfile.TemporaryDirectory() as td:
db = Database(Path(td) / "fv.sqlite")
db.connect()
db.upsert_event(ev)
unseen = db.feed_candidates(kinds, hide_nsfw=False, limit=50, viewer_pubkey=pk, exclude_viewed=True)
assert len(unseen) == 1 and unseen[0]["id"] == eid
db.mark_feed_viewed(pk, eid)
empty = db.feed_candidates(kinds, hide_nsfw=False, limit=50, viewer_pubkey=pk, exclude_viewed=True)
assert empty == []
again = db.feed_candidates(kinds, hide_nsfw=False, limit=50, viewer_pubkey=pk, exclude_viewed=False)
assert len(again) == 1 and again[0]["id"] == eid

76
tests/test_profile_interactions.py

@ -0,0 +1,76 @@
"""Outgoing interaction rows for profile activity column."""
import tempfile
from pathlib import Path
from typing import cast
from imwald.core.database import Database
from imwald.core.nostr_crypto import build_signed_event, pubkey_hex_from_secret
def _sk() -> bytes:
return bytes.fromhex("3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683")
def _sk_other() -> bytes:
"""Second valid curve key (distinct from :func:`_sk`)."""
return (3).to_bytes(32, "big")
def test_list_pubkey_outgoing_interactions_excludes_self_reply() -> None:
sk = _sk()
pk = pubkey_hex_from_secret(sk)
root = build_signed_event(
_sk_other(),
created_at=1,
kind=1,
tags=[],
content="their note",
)
rid = cast(str, root["id"])
to_other = build_signed_event(
sk,
created_at=2,
kind=1,
tags=[["e", rid, "", "reply"]],
content="hi there",
)
to_self = build_signed_event(
sk,
created_at=3,
kind=1,
tags=[["e", to_other["id"], "", "reply"]],
content="talk to myself",
)
with tempfile.TemporaryDirectory() as td:
db = Database(Path(td) / "pi.sqlite")
db.connect()
db.upsert_event(root)
db.upsert_event(to_other)
db.upsert_event(to_self)
got = db.list_pubkey_outgoing_interactions(pk, limit=20)
assert len(got) == 1
assert got[0]["id"] == to_other["id"]
assert str(got[0]["target_event_id"]).lower() == rid.lower()
assert str(got[0]["target_pubkey"]).lower() == str(root["pubkey"]).lower()
def test_list_pubkey_outgoing_interactions_includes_reaction() -> None:
sk = _sk()
pk = pubkey_hex_from_secret(sk)
root = build_signed_event(
_sk_other(),
created_at=10,
kind=1,
tags=[],
content="note",
)
rid = cast(str, root["id"])
rx = build_signed_event(sk, created_at=11, kind=7, tags=[["e", rid]], content="+")
with tempfile.TemporaryDirectory() as td:
db = Database(Path(td) / "pi2.sqlite")
db.connect()
db.upsert_event(root)
db.upsert_event(rx)
got = db.list_pubkey_outgoing_interactions(pk, limit=20)
assert len(got) == 1 and int(got[0]["kind"]) == 7
Loading…
Cancel
Save