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"(div|blockquote|h[1-6]|li|tr)\s*>", "\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'
'
+ )
+
+ 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'
'
+ )
+ 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