From f993535b31522e9b16a7eeb601a0a43bece9f8ea Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 19 Apr 2026 12:57:22 +0200 Subject: [PATCH] fix custom emojis --- src/imwald/core/database.py | 110 ++++++++++++++++++++++++ src/imwald/core/md_render.py | 71 +++++++++++++-- src/imwald/core/nip30_emoji.py | 97 +++++++++++++++++++++ src/imwald/ui/composer_dialog.py | 10 ++- src/imwald/ui/feed_page.py | 60 +++++++++++-- src/imwald/ui/main_window.py | 10 ++- src/imwald/ui/markdown_editor_widget.py | 20 ++++- src/imwald/ui/notifications_page.py | 28 +++++- src/imwald/ui/search_page.py | 12 ++- tests/test_nip30_emoji.py | 104 ++++++++++++++++++++++ 10 files changed, 499 insertions(+), 23 deletions(-) create mode 100644 src/imwald/core/nip30_emoji.py create mode 100644 tests/test_nip30_emoji.py diff --git a/src/imwald/core/database.py b/src/imwald/core/database.py index d3c0633..09aa636 100644 --- a/src/imwald/core/database.py +++ b/src/imwald/core/database.py @@ -10,6 +10,8 @@ from pathlib import Path from collections.abc import Generator, Iterable from typing import Any, TypedDict, cast +from imwald.core.nip30_emoji import nip30_emoji_urls_from_tags, parse_kind30030_a_coordinate + SCHEMA_VERSION = 2 @@ -633,6 +635,114 @@ class Database: out[pk] = {"content": row["content"] or "", "created_at": int(row["created_at"])} return out + def _nip30_d_tag_from_tags_json(self, tags_json: str) -> str: + try: + raw = json.loads(tags_json or "[]") + except json.JSONDecodeError: + return "" + if not isinstance(raw, list): + return "" + for row_obj in cast(list[object], raw): + if not isinstance(row_obj, list): + continue + row = cast(list[object], row_obj) + if len(row) < 2: + continue + if str(row[0]).lower() == "d": + return str(row[1] or "") + return "" + + def _latest_kind30030_tags(self, pubkey: str, d_value: str) -> list[list[str]] | None: + """Newest kind-30030 for ``pubkey`` + ``d`` tag (from ``tags_json``).""" + cur = self.conn().execute( + """ + SELECT tags_json FROM events + WHERE deleted = 0 AND kind = 30030 AND lower(pubkey) = lower(?) + ORDER BY created_at DESC + """, + (pubkey,), + ) + for row in cur: + tj = row["tags_json"] + if not isinstance(tj, str) or not tj.strip(): + continue + if self._nip30_d_tag_from_tags_json(tj) != d_value: + continue + try: + loaded = json.loads(tj) + except json.JSONDecodeError: + continue + if isinstance(loaded, list): + return cast(list[list[str]], loaded) + return None + + def get_author_nip30_emoji_urls(self, pubkey: str) -> dict[str, str]: + """ + NIP-30 inventory for a pubkey (same layering as Jumble): kind 0 ``emoji`` tags, + latest kind 10030 (inline + ``a`` → 30030 packs), then all kind 30030 from that author. + Later sources overwrite duplicate shortcodes (case-insensitive key). + """ + pk = pubkey.strip().lower() + if len(pk) != 64 or any(c not in "0123456789abcdef" for c in pk): + return {} + merged: dict[str, str] = {} + + def merge(tags: list[list[str]]) -> None: + for k, v in nip30_emoji_urls_from_tags(tags).items(): + merged[k] = v + + c = self.conn() + for kind in (0, 10030): + row = c.execute( + """ + SELECT tags_json FROM events + WHERE deleted = 0 AND kind = ? AND lower(pubkey) = lower(?) + ORDER BY created_at DESC LIMIT 1 + """, + (kind, pk), + ).fetchone() + if not row or not row["tags_json"]: + continue + try: + raw = json.loads(row["tags_json"]) + except json.JSONDecodeError: + continue + if not isinstance(raw, list): + continue + tag_list = cast(list[list[str]], raw) + merge(tag_list) + if kind == 10030: + for t in tag_list: + if len(t) < 2 or str(t[0]).lower() != "a": + continue + parsed = parse_kind30030_a_coordinate(str(t[1] or "")) + if not parsed: + continue + set_pk, d_tag = parsed + pack = self._latest_kind30030_tags(set_pk, d_tag) + if pack: + merge(pack) + + cur30030 = c.execute( + """ + SELECT tags_json FROM events + WHERE deleted = 0 AND kind = 30030 AND lower(pubkey) = lower(?) + ORDER BY created_at ASC + """, + (pk,), + ) + for row in cur30030: + tj = row["tags_json"] + if not isinstance(tj, str) or not tj.strip(): + continue + try: + raw = json.loads(tj) + except json.JSONDecodeError: + continue + if isinstance(raw, list): + merge(cast(list[list[str]], raw)) + return merged + def event_engagement_stats(self, event_id: str) -> dict[str, Any]: """Counts from local DB: zaps (9735), reactions (7), boosts (6), quotes (``q`` on kind 1).""" c = self.conn() diff --git a/src/imwald/core/md_render.py b/src/imwald/core/md_render.py index 6f0557e..01b273e 100644 --- a/src/imwald/core/md_render.py +++ b/src/imwald/core/md_render.py @@ -13,17 +13,19 @@ from typing import TYPE_CHECKING, cast import nh3 +from imwald.core.database import Database +from imwald.core.nip30_emoji import preprocess_nip30_emoji_markdown, nip30_emoji_urls_from_tags from imwald.core.nostr_entity_render import preprocess_nostr_entities if TYPE_CHECKING: - from imwald.core.database import Database from quickjs import Context log = logging.getLogger(__name__) # Bare HTTPS image URLs in notes → Markdown image (so renderer emits ````). +# Exclude ``<`` so ``![]()`` / HTML ``src="https://…"`` are not double-wrapped. _STANDALONE_IMAGE_URL = re.compile( - r"(?()]+?\.(?:png|jpe?g|gif|webp|avif))(?:\?[^\s<>]*)?(?![)\]])", + r'(?()]+?\.(?:png|jpe?g|gif|webp|avif))(?:\?[^\s<>]*)?(?![)\]])', re.IGNORECASE, ) @@ -75,6 +77,7 @@ def _nh3_attributes() -> dict[str, set[str]]: img_a = raw.get("img") if img_a is not None: img_a.add("style") + img_a.add("class") _nh3_attrs_merged = dict(raw) return _nh3_attrs_merged @@ -146,11 +149,35 @@ def preprocess_standalone_image_urls(md: str) -> str: return _STANDALONE_IMAGE_URL.sub(repl, md or "") -def markdown_html_fragment(md: str, *, db: Database | None = None) -> str: +def _merged_nip30_emoji_urls( + db: Database | None, + nip30_tags: list[list[str]] | None, + nip30_author_pubkey: str | None, +) -> dict[str, str]: + """Event ``emoji`` tags override the author's published inventory (Jumble order).""" + ev = nip30_emoji_urls_from_tags(nip30_tags) + if db is None or not nip30_author_pubkey: + return ev + pk = nip30_author_pubkey.strip().lower() + if len(pk) != 64: + return ev + auth = db.get_author_nip30_emoji_urls(pk) + return {**auth, **ev} + + +def markdown_html_fragment( + md: str, + *, + db: Database | None = None, + nip30_tags: list[list[str]] | None = None, + nip30_author_pubkey: str | None = None, +) -> str: """Sanitized HTML fragment (body inner HTML) for embedding in templates.""" # Standalone image URLs first: nostr preprocessing injects ````; the # image-url pass must not run on that HTML or it corrupts attributes into Markdown images. md = preprocess_standalone_image_urls(md or "") + emoji_urls = _merged_nip30_emoji_urls(db, nip30_tags, nip30_author_pubkey) + md = preprocess_nip30_emoji_markdown(md, emoji_urls) md = preprocess_nostr_entities(md, db) raw = _render_marked_js(md) if raw is None: @@ -158,14 +185,23 @@ def markdown_html_fragment(md: str, *, db: Database | None = None) -> str: return _nh3_clean(raw) -def markdown_plain_summary(md: str, *, max_len: int = 100, db: Database | None = None) -> str: +def markdown_plain_summary( + md: str, + *, + max_len: int = 100, + db: Database | None = None, + nip30_tags: list[list[str]] | None = None, + nip30_author_pubkey: str | None = None, +) -> str: """ Plain-text one-line preview for list widgets: same pipeline as ``markdown_html_fragment``, then strip tags and collapse whitespace (no Markdown noise in the UI chrome). """ # Cap source length so list views (search, notifications, threads) do not parse huge notes. src = (md or "")[:1200] - frag = markdown_html_fragment(src, db=db) + frag = markdown_html_fragment( + src, db=db, nip30_tags=nip30_tags, nip30_author_pubkey=nip30_author_pubkey + ) plain = html.unescape(re.sub(r"<[^>]+>", " ", frag)) plain = re.sub(r"\s+", " ", plain).strip() if len(plain) <= max_len: @@ -173,10 +209,19 @@ def markdown_plain_summary(md: str, *, max_len: int = 100, db: Database | None = return plain[: max_len - 1] + "…" -def markdown_to_plain_text(md: str, *, max_source: int = 200_000, db: Database | None = None) -> str: +def markdown_to_plain_text( + md: str, + *, + max_source: int = 200_000, + db: Database | None = None, + nip30_tags: list[list[str]] | None = None, + nip30_author_pubkey: str | None = None, +) -> str: """Full plain text from Markdown (for thread bodies); keeps paragraph breaks.""" src = (md or "")[:max_source] - frag = markdown_html_fragment(src, db=db) + frag = markdown_html_fragment( + src, db=db, nip30_tags=nip30_tags, nip30_author_pubkey=nip30_author_pubkey + ) frag = re.sub(r"", "\n", frag, flags=re.I) frag = re.sub(r"", "\n\n", frag, flags=re.I) frag = re.sub(r"", "\n", frag, flags=re.I) @@ -198,9 +243,17 @@ img{max-width:min(100%,400px);height:auto;} """ -def markdown_html_document(md: str, *, db: Database | None = None) -> str: +def markdown_html_document( + md: str, + *, + db: Database | None = None, + nip30_tags: list[list[str]] | None = None, + nip30_author_pubkey: str | None = None, +) -> str: """Full HTML document for ``QTextBrowser`` preview panes.""" - inner = markdown_html_fragment(md, db=db) + inner = markdown_html_fragment( + md, db=db, nip30_tags=nip30_tags, nip30_author_pubkey=nip30_author_pubkey + ) return ( "" f"{_PREVIEW_CSS}{inner}" diff --git a/src/imwald/core/nip30_emoji.py b/src/imwald/core/nip30_emoji.py new file mode 100644 index 0000000..21ce8ab --- /dev/null +++ b/src/imwald/core/nip30_emoji.py @@ -0,0 +1,97 @@ +"""NIP-30 custom emoji: ``:shortcode:`` in text from ``emoji`` tags on the same event.""" + +from __future__ import annotations + +import html +import re +from typing import cast + +# NIP-30: shortcode names are alphanumeric, hyphens, and underscores. +_SHORTCODE = re.compile(r":([a-zA-Z0-9_-]+):") + + +def parse_kind30030_a_coordinate(coord: str) -> tuple[str, str] | None: + """ + Parse an ``a`` tag value like ``30030::`` (NIP-33 replaceable). + Returns ``(pubkey_lower, d_value)`` or ``None``. + """ + parts = coord.strip().split(":", 2) + if len(parts) != 3: + return None + kind_s, pk, d_val = parts[0].strip(), parts[1].strip().lower(), parts[2] + if kind_s != "30030": + return None + if len(pk) != 64 or any(c not in "0123456789abcdef" for c in pk): + return None + if not d_val: + return None + return pk, d_val + + +def nip30_emoji_urls_from_tags(tags: object) -> dict[str, str]: + """ + Build ``shortcode_lower -> https URL`` from ``["emoji", shortcode, url, ...?]`` tags. + Only ``http`` / ``https`` image URLs are kept. + """ + out: dict[str, str] = {} + if not isinstance(tags, list): + return out + for row_obj in cast(list[object], tags): + if not isinstance(row_obj, list): + continue + r = cast(list[object], row_obj) + if len(r) < 3: + continue + if str(r[0]).lower() != "emoji": + continue + code = str(r[1]).strip() + url = str(r[2]).strip() + if not code or not url or not re.fullmatch(r"[a-zA-Z0-9_-]+", code): + continue + if not (url.startswith("https://") or url.startswith("http://")): + continue + out[code.lower()] = url + return out + + +def _replace_shortcodes_in_segment(segment: str, url_by_shortcode: dict[str, str]) -> str: + if not url_by_shortcode or not segment: + return segment + + def repl(m: re.Match[str]) -> str: + raw = m.group(1) + u = url_by_shortcode.get(raw.lower()) + if not u: + return m.group(0) + esc_u = html.escape(u, quote=True) + esc_alt = html.escape(f":{raw}:", quote=True) + return ( + f'{esc_alt}' + ) + + return _SHORTCODE.sub(repl, segment) + + +def preprocess_nip30_emoji_markdown(md: str, url_by_shortcode: dict[str, str]) -> str: + """ + Replace ``:shortcode:`` with inline ```` (NIP-30) in Markdown source. + Skips fenced ``` … ``` blocks so shortcodes inside code samples stay literal. + """ + if not url_by_shortcode or not md: + return md + pieces: list[str] = [] + i = 0 + while i < len(md): + j = md.find("```", i) + end = len(md) if j == -1 else j + pieces.append(_replace_shortcodes_in_segment(md[i:end], url_by_shortcode)) + if j == -1: + break + k = md.find("```", j + 3) + if k == -1: + pieces.append(md[j:]) + break + pieces.append(md[j : k + 3]) + i = k + 3 + return "".join(pieces) diff --git a/src/imwald/ui/composer_dialog.py b/src/imwald/ui/composer_dialog.py index 8f34a0c..79ac782 100644 --- a/src/imwald/ui/composer_dialog.py +++ b/src/imwald/ui/composer_dialog.py @@ -60,7 +60,15 @@ class ComposerDialog(QDialog): self._tags = QLineEdit() self._tags.setPlaceholderText('JSON array of tags, e.g. [["t","nostr"]]') - self._content = MarkdownBodyEditor(db=db) + nip30_preview: list[list[str]] | None = None + if edit_from: + tg = edit_from.get("tags") + nip30_preview = cast(list[list[str]], tg) if isinstance(tg, list) else None + ap = account.pubkey.strip().lower() + nip30_author = ap if len(ap) == 64 else None + self._content = MarkdownBodyEditor( + db=db, nip30_tags=nip30_preview, nip30_author_pubkey=nip30_author + ) self._hint = QLabel("Suggestions: " + ", ".join(f'["{t}","…"]' for t in TAG_SUGGESTIONS[:4])) buttons = QDialogButtonBox( diff --git a/src/imwald/ui/feed_page.py b/src/imwald/ui/feed_page.py index f43abb1..0719631 100644 --- a/src/imwald/ui/feed_page.py +++ b/src/imwald/ui/feed_page.py @@ -4,6 +4,7 @@ from __future__ import annotations import html import json +import re from collections.abc import Sequence from typing import Any, cast @@ -35,6 +36,14 @@ from imwald.ui.theme import BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED FEED_KINDS = (1, 20, 21, 30023, 9802, 11) +# NIP-25 reaction content that is only a custom shortcode (Jumble-style whole-string match). +_REACTION_WHOLE_SHORTCODE = re.compile(r"^:([a-zA-Z0-9_-]+):$") + + +def _nip30_tags(ev_row: dict[str, Any]) -> list[list[str]] | None: + t = ev_row.get("tags") + return cast(list[list[str]], t) if isinstance(t, list) else None + def _set_plain_height_to_content(te: QPlainTextEdit) -> None: doc = te.document() @@ -51,7 +60,11 @@ def _set_plain_height_to_content(te: QPlainTextEdit) -> None: te.setFixedHeight(int(max(h + margins + fr, 44))) -def _format_engagement_html(stats: dict[str, Any]) -> str: +def _format_engagement_html( + stats: dict[str, Any], + *, + reaction_nip30_urls: dict[str, str] | None = None, +) -> str: parts: list[str] = [] z = int(stats.get("zaps") or 0) b = int(stats.get("boosts") or 0) @@ -87,10 +100,29 @@ def _format_engagement_html(stats: dict[str, Any]) -> str: c = int(str(c_o)) if str(c_o).isdigit() else 0 pairs.append((em, c)) emoji_bits: list[str] = [] + rx_urls = reaction_nip30_urls or {} for em, c in pairs: - e = html.escape(em if em != "+" else "❤", quote=False) + raw_disp = em if em != "+" else "❤" + esc_title = html.escape(raw_disp, quote=True) + m_sc = _REACTION_WHOLE_SHORTCODE.match(raw_disp.strip()) + if m_sc and rx_urls: + u = rx_urls.get(m_sc.group(1).lower()) + if u: + esc_u = html.escape(u, quote=True) + esc_alt = html.escape(raw_disp, quote=True) + e = ( + f'{esc_alt}' + ) + else: + e = html.escape(raw_disp, quote=False) + else: + e = html.escape(raw_disp, quote=False) if c > 1: - emoji_bits.append(f'{e}{c}') + emoji_bits.append( + f'{e}' + f'{c}' + ) else: emoji_bits.append(f'{e}') em_row = "   ".join(emoji_bits) if emoji_bits else "" @@ -323,9 +355,13 @@ class FeedPage(QWidget): 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)) + op_pk = str(ev["pubkey"]) + author_nip30 = self._db.get_author_nip30_emoji_urls(op_pk) + self._engagement_label.setText( + _format_engagement_html(stats, reaction_nip30_urls=author_nip30) + ) - pk = ev["pubkey"] + pk = op_pk prof_row = self._db.get_latest_kind0_profile(pk) parsed = parse_kind0_profile(prof_row["content"] if prof_row else "") npub = encode_npub(pk) @@ -354,7 +390,12 @@ class FeedPage(QWidget): tr = f"
Trending slice (nostrarchives)
" eid = html.escape(ev["id"]) - md_body = markdown_html_fragment(ev.get("content") or "", db=self._db) + md_body = markdown_html_fragment( + ev.get("content") or "", + db=self._db, + nip30_tags=_nip30_tags(ev), + nip30_author_pubkey=pk, + ) body = ( "" f"{FEED_DOC_CSS}" @@ -375,7 +416,12 @@ class FeedPage(QWidget): rpk = str(r["pubkey"]).lower() pr = profiles.get(rpk) rp = parse_kind0_profile(pr["content"] if pr else "") - plain = markdown_to_plain_text(r.get("content") or "", db=self._db) + plain = markdown_to_plain_text( + r.get("content") or "", + db=self._db, + nip30_tags=_nip30_tags(r), + nip30_author_pubkey=str(r.get("pubkey") or ""), + ) card = QFrame() card.setObjectName("ReplyCard") diff --git a/src/imwald/ui/main_window.py b/src/imwald/ui/main_window.py index 1879689..d173506 100644 --- a/src/imwald/ui/main_window.py +++ b/src/imwald/ui/main_window.py @@ -266,7 +266,15 @@ class MainWindow(QMainWindow): d.setWindowTitle("Your latest events") lw = QListWidget() for ev in rows: - snippet = markdown_plain_summary(ev.get("content") or "", max_len=56) + tags = ev.get("tags") + nip = cast(list[list[str]], tags) if isinstance(tags, list) else None + snippet = markdown_plain_summary( + ev.get("content") or "", + max_len=56, + db=self._db, + nip30_tags=nip, + nip30_author_pubkey=str(ev.get("pubkey") or "") or None, + ) it = QListWidgetItem(f"k{ev['kind']} {ev['id'][:16]}… — {snippet}") it.setData(Qt.ItemDataRole.UserRole, ev["id"]) lw.addItem(it) diff --git a/src/imwald/ui/markdown_editor_widget.py b/src/imwald/ui/markdown_editor_widget.py index 5b905c3..db90d85 100644 --- a/src/imwald/ui/markdown_editor_widget.py +++ b/src/imwald/ui/markdown_editor_widget.py @@ -13,9 +13,18 @@ from imwald.core.md_render import markdown_html_document class MarkdownBodyEditor(QWidget): """Plain-text Markdown editor with live rendered preview (local ``marked`` + nh3).""" - def __init__(self, parent: QWidget | None = None, *, db: Database | None = None) -> None: + def __init__( + self, + parent: QWidget | None = None, + *, + db: Database | None = None, + nip30_tags: list[list[str]] | None = None, + nip30_author_pubkey: str | None = None, + ) -> None: super().__init__(parent) self._db = db + self._nip30_tags = nip30_tags + self._nip30_author_pubkey = nip30_author_pubkey self._split = QSplitter(Qt.Orientation.Horizontal) self._source = QPlainTextEdit() self._source.setPlaceholderText("Markdown source — preview updates as you type") @@ -54,7 +63,14 @@ class MarkdownBodyEditor(QWidget): self._update_preview() def _update_preview(self) -> None: - self._preview.setHtml(markdown_html_document(self._source.toPlainText(), db=self._db)) + self._preview.setHtml( + markdown_html_document( + self._source.toPlainText(), + db=self._db, + nip30_tags=self._nip30_tags, + nip30_author_pubkey=self._nip30_author_pubkey, + ) + ) def setPlainText(self, text: str) -> None: self._source.setPlainText(text) diff --git a/src/imwald/ui/notifications_page.py b/src/imwald/ui/notifications_page.py index aad5a87..d91f23e 100644 --- a/src/imwald/ui/notifications_page.py +++ b/src/imwald/ui/notifications_page.py @@ -2,6 +2,9 @@ from __future__ import annotations +import json +from typing import cast + from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import QLabel, QListWidget, QListWidgetItem, QTabWidget, QVBoxLayout, QWidget @@ -59,7 +62,8 @@ class NotificationsPage(QWidget): lw.clear() cur = self._db.conn().execute( """ - SELECT n.source_event_id, n.kind, n.read, n.created_at, e.content AS content + SELECT n.source_event_id, n.kind, n.read, n.created_at, e.content AS content, + e.tags_json AS tags_json, e.pubkey AS source_pubkey FROM notifications n LEFT JOIN events e ON e.id = n.source_event_id AND e.deleted = 0 WHERE n.recipient_pubkey=? ORDER BY n.created_at DESC LIMIT 200 @@ -67,7 +71,27 @@ class NotificationsPage(QWidget): (pubkey,), ) for row in cur: - snippet = markdown_plain_summary(row["content"] or "", max_len=56) if row["content"] else "" + nip: list[list[str]] | None = None + raw_tj = row["tags_json"] + if isinstance(raw_tj, str) and raw_tj.strip(): + try: + loaded = json.loads(raw_tj) + if isinstance(loaded, list): + nip = cast(list[list[str]], loaded) + except json.JSONDecodeError: + nip = None + spk = row["source_pubkey"] if row["source_pubkey"] else None + snippet = ( + markdown_plain_summary( + row["content"] or "", + max_len=56, + db=self._db, + nip30_tags=nip, + nip30_author_pubkey=str(spk) if spk else None, + ) + if row["content"] + else "" + ) tail = f" — {snippet}" if snippet else "" title = f"{row['kind']} {row['source_event_id'][:12]}… read={row['read']}{tail}" it = QListWidgetItem(title) diff --git a/src/imwald/ui/search_page.py b/src/imwald/ui/search_page.py index 9a360f2..fc7b0a3 100644 --- a/src/imwald/ui/search_page.py +++ b/src/imwald/ui/search_page.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import cast + from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget @@ -36,7 +38,15 @@ class SearchPage(QWidget): if not q: return for ev in self._db.search_local(q, limit=200): - snippet = markdown_plain_summary(ev.get("content") or "", max_len=72) + tags = ev.get("tags") + nip = cast(list[list[str]], tags) if isinstance(tags, list) else None + snippet = markdown_plain_summary( + ev.get("content") or "", + max_len=72, + db=self._db, + nip30_tags=nip, + nip30_author_pubkey=str(ev.get("pubkey") or "") or None, + ) title = f"{ev['kind']} {ev['id'][:12]}… — {snippet}" it = QListWidgetItem(title) it.setData(Qt.ItemDataRole.UserRole, ev["id"]) diff --git a/tests/test_nip30_emoji.py b/tests/test_nip30_emoji.py new file mode 100644 index 0000000..50003e3 --- /dev/null +++ b/tests/test_nip30_emoji.py @@ -0,0 +1,104 @@ +"""NIP-30 custom emoji: ``emoji`` tags + ``:shortcode:`` in note text.""" + +import tempfile +from pathlib import Path + +from imwald.core.database import Database +from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary +from imwald.core.nip30_emoji import ( + nip30_emoji_urls_from_tags, + parse_kind30030_a_coordinate, + preprocess_nip30_emoji_markdown, +) +from imwald.core.nostr_crypto import build_signed_event, pubkey_hex_from_secret + + +def test_urls_from_tags() -> None: + tags = [ + ["emoji", "100percent", "https://example.com/a.png"], + ["emoji", "bad", "ftp://x/y.png"], + ["emoji", "", "https://example.com/z.png"], + ["t", "nostr"], + ] + m = nip30_emoji_urls_from_tags(tags) + assert m == {"100percent": "https://example.com/a.png"} + + +def test_preprocess_skips_fenced_blocks() -> None: + urls = {"x": "https://example.com/x.png"} + md = "```\n:x:\n```\nline :x: end" + out = preprocess_nip30_emoji_markdown(md, urls) + assert ":x:" in out.split("```")[1] + assert ' None: + tags = [["emoji", "100percent", "https://example.com/e.png"]] + html = markdown_html_fragment("Hello :100percent: world", nip30_tags=tags) + assert 'class="nip30-emoji"' in html + assert 'src="https://example.com/e.png"' in html + assert "Hello" in html and "world" in html + + +def test_parse_kind30030_a_coordinate() -> None: + pk = "a" * 64 + assert parse_kind30030_a_coordinate(f"30030:{pk}:my-pack") == (pk, "my-pack") + assert parse_kind30030_a_coordinate("1:xx:yy") is None + assert parse_kind30030_a_coordinate("nope") is None + + +def test_author_kind0_inventory_resolves_shortcode_in_markdown() -> None: + """Jumble-style: shortcodes in the note resolve from the author's kind 0 ``emoji`` tags.""" + sk = bytes.fromhex("3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683") + pk = pubkey_hex_from_secret(sk) + k0 = build_signed_event( + sk, + created_at=1, + kind=0, + tags=[["emoji", "chadyes_sm", "https://example.com/chad.png"]], + content="{}", + ) + with tempfile.TemporaryDirectory() as td: + db = Database(Path(td) / "nip30.sqlite") + db.connect() + db.upsert_event(k0) + html = markdown_html_fragment( + "Hi :chadyes_sm: bye", + db=db, + nip30_tags=None, + nip30_author_pubkey=pk, + ) + assert "nip30-emoji" in html + assert "example.com/chad.png" in html + + +def test_event_emoji_tags_override_author_inventory() -> None: + sk = bytes.fromhex("3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683") + pk = pubkey_hex_from_secret(sk) + k0 = build_signed_event( + sk, + created_at=1, + kind=0, + tags=[["emoji", "x", "https://example.com/from0.png"]], + content="{}", + ) + with tempfile.TemporaryDirectory() as td: + db = Database(Path(td) / "nip30b.sqlite") + db.connect() + db.upsert_event(k0) + html = markdown_html_fragment( + ":x:", + db=db, + nip30_tags=[["emoji", "x", "https://example.com/from_note.png"]], + nip30_author_pubkey=pk, + ) + assert "from_note.png" in html + assert "from0.png" not in html + + +def test_plain_summary_strips_inline_emoji_img() -> None: + """List previews strip HTML; NIP-30 ```` does not leave the shortcode in plain text.""" + tags = [["emoji", "x", "https://example.com/x.png"]] + s = markdown_plain_summary("Hi :x: bye", max_len=200, nip30_tags=tags) + assert "Hi" in s and "bye" in s + assert ":x:" not in s