From 5949fba143ccb46106f493576cbe53f9e2641b86 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 19 Apr 2026 15:09:54 +0200 Subject: [PATCH] bug-fixes --- src/imwald/core/database.py | 96 +++++++++++- src/imwald/ui/feed_page.py | 36 ++--- src/imwald/ui/profile_page.py | 235 +++++++++++++++++++++++------ tests/test_feed_views.py | 31 ++++ tests/test_profile_interactions.py | 76 ++++++++++ 5 files changed, 408 insertions(+), 66 deletions(-) create mode 100644 tests/test_feed_views.py create mode 100644 tests/test_profile_interactions.py diff --git a/src/imwald/core/database.py b/src/imwald/core/database.py index ac979be..409ffe2 100644 --- a/src/imwald/core/database.py +++ b/src/imwald/core/database.py @@ -57,6 +57,20 @@ THREAD_REPLY_KINDS: tuple[int, ...] = ( 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: return 30000 <= kind < 40000 @@ -510,6 +524,82 @@ class Database: ) 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 author’s 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: cur = self.conn().execute( "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, exclude_viewed: bool = True, ) -> 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) placeholders = ",".join("?" * len(kind_list)) sql = f""" @@ -601,7 +695,7 @@ class Database: return out 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: c.execute( """ diff --git a/src/imwald/ui/feed_page.py b/src/imwald/ui/feed_page.py index 0e11025..56465af 100644 --- a/src/imwald/ui/feed_page.py +++ b/src/imwald/ui/feed_page.py @@ -350,17 +350,29 @@ class FeedPage(QWidget): """Per-device feed history; logged-out users share `_anon`.""" 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" + vk = self._feed_viewer_key() raw = self._db.feed_candidates( FEED_KINDS, hide_nsfw=hide, limit=500, - viewer_pubkey=self._feed_viewer_key(), + viewer_pubkey=vk, exclude_viewed=True, ) - ranked = self._ranker.rank_feed(raw, self._my_pubkey, self._following, self._list30000_pubkeys) - self._queue = ranked + if not raw: + 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._show_current() @@ -375,15 +387,7 @@ class FeedPage(QWidget): self._queue = [cast(dict[str, Any], ev)] self._show_current() return - hide = self._db.get_setting("hide_nsfw", "1") == "1" - 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) + ranked = self._ranked_feed_candidates() self._queue = ranked found = False for i, ev in enumerate(self._queue): @@ -404,8 +408,6 @@ class FeedPage(QWidget): self._queue = [cast(dict[str, Any], ev)] self._index = 0 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: """Full OP ``QTextBrowser`` document: compact author row + note body.""" @@ -545,6 +547,7 @@ class FeedPage(QWidget): self._op_ev_snapshot = ev body = self._build_op_html(ev, None) self._op.setHtml(body) + self._db.mark_feed_viewed(self._feed_viewer_key(), root_id) pk = op_pk prof_row2 = self._db.get_latest_kind0_profile(pk) @@ -670,9 +673,6 @@ class FeedPage(QWidget): def _next(self) -> None: if not self._queue: 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._show_current() diff --git a/src/imwald/ui/profile_page.py b/src/imwald/ui/profile_page.py index 0f20758..c8b6f05 100644 --- a/src/imwald/ui/profile_page.py +++ b/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). _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_AVATAR = 136 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): - """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_profile = Signal(str) @@ -338,6 +351,13 @@ class ProfilePage(QWidget): self._feed_body.setOpenExternalLinks(False) self._feed_body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 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) left_lay = QVBoxLayout(self._left_column) left_lay.setContentsMargins(0, 0, 0, 0) @@ -349,12 +369,15 @@ class ProfilePage(QWidget): split.setChildrenCollapsible(False) split.addWidget(self._left_column) 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(1, 3) - split.setSizes([400, 780]) - self._left_column.setMinimumWidth(300) - self._feed_body.setMinimumWidth(260) + split.setStretchFactor(1, 4) + split.setStretchFactor(2, 2) + split.setSizes([300, 640, 280]) + self._left_column.setMinimumWidth(260) + self._feed_body.setMinimumWidth(240) + self._interact_body.setMinimumWidth(220) lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.addWidget(split) @@ -374,6 +397,9 @@ class ProfilePage(QWidget): # resetting placeholders + bumping fetch generations on every refresh() while ingest runs. self._cover_banner_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() def tab_title(self) -> str: @@ -416,18 +442,6 @@ class ProfilePage(QWidget): lud06_s = lud06_raw.strip() if isinstance(lud06_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) - pay_rows: list[str] = [] - if lud06_s: - pay_rows.append( - f"

lud06 (LNURL / NIP-57): " - f"{html.escape(lud06_s[:200])}

" - ) - if lud16_s: - pay_rows.append( - f"

lud16 (Lightning address or HTTPS LNURL): " - f"{html.escape(lud16_s[:200])}

" - ) - pay_static = "".join(pay_rows) live_lnurl = "" if from_lnurl and lnurl_html is not None: live_lnurl = lnurl_html @@ -435,10 +449,43 @@ class ProfilePage(QWidget): self._lnurl_gen += 1 gen = self._lnurl_gen self._lnurl_pool.start(_ProfileLnurlRunnable(lnurls, gen, self._lnurl_sigs)) - live_lnurl = f"

Fetching LNURL-pay metadata…

" + live_lnurl = f"

Loading tip details…

" + tech_dl: list[str] = [] + if lud06_s: + tech_dl.append( + f"
lud06
" + f"
" + f"{html.escape(lud06_s[:400])}
" + ) + if lud16_s: + tech_dl.append( + f"
lud16
" + f"
" + f"{html.escape(lud16_s[:400])}
" + ) + tech_block = "" + if tech_dl: + tech_block = ( + f"
" + f"Raw LNURL fields
{''.join(tech_dl)}
" + ) + tips_head = "" + if lud16_s: + if "@" in lud16_s and not lud16_s.lower().startswith("http"): + tips_head = ( + f"

{html.escape(lud16_s)}

" + f"

Lightning tips

" + ) + elif lud16_s.lower().startswith("https://"): + tips_head = f"

LNURL-pay (HTTPS) on file.

" + elif lud06_s: + tips_head = ( + f"

LNURL (bech32) on file — " + f"expand Raw LNURL fields if you need the decoded URL.

" + ) pay_card = "" - if pay_static or live_lnurl: - pay_card = f"{_sec_title('Lightning · NIP-57')}
{pay_static}{live_lnurl}
" + if tips_head or live_lnurl or tech_block: + pay_card = f"{_sec_title('Tips')}
{tips_head}{live_lnurl}{tech_block}
" disp_plain = display_name_from_profile_or_hex(parsed, pk) nip05_plain = (parsed.get("nip05") or "").strip() if parsed.get("nip05") else "" @@ -517,26 +564,29 @@ class ProfilePage(QWidget): raw_json = content or "" raw_esc = html.escape(raw_json[:12000] + ("…" if len(raw_json) > 12000 else ""), quote=False) json_inner = ( - f"{_sec_title('Kind 0 JSON')}" - f"
{raw_esc}
" + f"
" + f"Developer · raw kind 0 JSON" + f"
"
+            f"{raw_esc}
" ) k10002 = self._db.get_latest_kind10002_event(pk) relay_inner = ( - f"

" - f"No NIP-65 relay list (kind 10002) in local DB yet.

" + f"

" + f"No relay list stored locally yet (NIP-65).

" ) if k10002: reads, writes = parse_kind10002_tags(k10002.get("tags") or []) - r_esc = "
".join(html.escape(u) for u in reads[:40]) - w_esc = "
".join(html.escape(u) for u in writes[:40]) + r_esc = "
".join(html.escape(u) for u in reads[:24]) + w_esc = "
".join(html.escape(u) for u in writes[:24]) relay_inner = ( - f"{_sec_title('Relays · NIP-65 (kind 10002)')}" - f"

Read

" - f"
{r_esc or '—'}
" - f"

Write

" - f"
{w_esc or '—'}
" + f"{_sec_title('Relays')}" + f"

Outbox (read / write)

" + f"

Read

" + f"
{r_esc or '—'}
" + f"

Write

" + f"
{w_esc or '—'}
" ) follows = self._db.get_latest_kind3_contact_pubkeys(pk, limit=400) @@ -547,23 +597,29 @@ class ProfilePage(QWidget): follow_lines.append( f'
{html.escape(np)}' - f' · {html.escape(fp[:16])}…
' + f' · {html.escape(fp[:16])}…' ) - _no_follow = f"No kind 3 in local DB." - follow_inner = ( - f"{_sec_title('Following · kind 3 (local)')}" - f"
{''.join(follow_lines) or _no_follow}
" - ) + _no_follow = f"No contact list in local DB yet." + follow_inner = f"{_sec_title('Following')}
{''.join(follow_lines) or _no_follow}
" nip30 = self._db.get_author_nip30_emoji_urls(pk) em_lines: list[str] = [] for short, url in sorted(nip30.items(), key=lambda x: x[0])[:48]: em_lines.append( f"
:{html.escape(short)}: " - f'{html.escape(url[:48])}…
' + f'' + f"{html.escape(url[:40])}…" ) - _no_emoji = f"No emoji packs indexed yet." - emoji_inner = f"{_sec_title('Custom emoji · NIP-30')}" f"
{''.join(em_lines) or _no_emoji}
" + _no_emoji = f"No custom emoji indexed yet." + if em_lines: + emoji_inner = ( + f"{_sec_title('Emoji')}" + f"

{len(nip30)} shortcodes (local).

" + f"
Show list" + f"
{''.join(em_lines)}
" + ) + else: + emoji_inner = f"{_sec_title('Emoji')}
{_no_emoji}
" notes = self._db.list_events_by_pubkey(pk, kinds=_PROFILE_NOTE_KINDS, limit=40) note_lines: list[str] = [] @@ -595,8 +651,8 @@ class ProfilePage(QWidget): _no_notes = "No matching notes stored yet." card_style = ( - f"background:{BG_CARD};border:1px solid {BORDER};border-radius:14px;" - f"padding:16px 18px;margin-bottom:14px" + f"background:{BG_CARD};border:1px solid {BORDER};border-radius:12px;" + f"padding:12px 14px;margin-bottom:12px" ) left_parts: list[str] = [] if about_inner: @@ -610,7 +666,7 @@ class ProfilePage(QWidget): left_doc = ( "" - f"{FEED_DOC_CSS}" + f"{FEED_DOC_CSS}" f"{''.join(left_parts)}" ) @@ -631,8 +687,93 @@ class ProfilePage(QWidget): f"{FEED_DOC_CSS}" f"{feed_intro}{feed_inner}" ) - self._left_body.setHtml(left_doc) - self._feed_body.setHtml(feed_doc) + + 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"
On " + f'parent note' + f' · ' + f"{html.escape(t_nm)}
" + ) + esc_h = html.escape(href_self, quote=True) + act_lines.append( + f'' + f"
" + f"{html.escape(k_lab)} · {html.escape(t_hi)}
" + f"
" + f"{html.escape(snip_i)}
{parent_href}" + f"
" + f"Open thread →
" + ) + _no_act = "No replies or reactions to other people's notes in the local database yet." + act_intro = ( + f'
' + f'
Activity
' + f'
' + f"Replies, boosts, and reactions that link to someone else's events " + f"(from this device’s index).
" + ) + act_inner = "".join(act_lines) if act_lines else f"

{html.escape(_no_act)}

" + interact_doc = ( + "" + f"{FEED_DOC_CSS}" + f"{act_intro}{act_inner}" + ) + + 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) + if self._feed_panel_sig != feed_sig: + self._feed_panel_sig = feed_sig + 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() if isinstance(tw, QTabWidget): i = tw.indexOf(self) diff --git a/tests/test_feed_views.py b/tests/test_feed_views.py new file mode 100644 index 0000000..560ff03 --- /dev/null +++ b/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 diff --git a/tests/test_profile_interactions.py b/tests/test_profile_interactions.py new file mode 100644 index 0000000..0022a17 --- /dev/null +++ b/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