Browse Source

fix custom emojis

master
Silberengel 2 weeks ago
parent
commit
f993535b31
  1. 110
      src/imwald/core/database.py
  2. 71
      src/imwald/core/md_render.py
  3. 97
      src/imwald/core/nip30_emoji.py
  4. 10
      src/imwald/ui/composer_dialog.py
  5. 60
      src/imwald/ui/feed_page.py
  6. 10
      src/imwald/ui/main_window.py
  7. 20
      src/imwald/ui/markdown_editor_widget.py
  8. 28
      src/imwald/ui/notifications_page.py
  9. 12
      src/imwald/ui/search_page.py
  10. 104
      tests/test_nip30_emoji.py

110
src/imwald/core/database.py

@ -10,6 +10,8 @@ from pathlib import Path @@ -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: @@ -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()

71
src/imwald/core/md_render.py

@ -13,17 +13,19 @@ from typing import TYPE_CHECKING, cast @@ -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 ``<img>``).
# Exclude ``<`` so ``![](<https://…>)`` / HTML ``src="https://…"`` are not double-wrapped.
_STANDALONE_IMAGE_URL = re.compile(
r"(?<![(\[])(https?://[^\s<>()]+?\.(?:png|jpe?g|gif|webp|avif))(?:\?[^\s<>]*)?(?![)\]])",
r'(?<![(\["\'<])(https?://[^\s<>()]+?\.(?:png|jpe?g|gif|webp|avif))(?:\?[^\s<>]*)?(?![)\]])',
re.IGNORECASE,
)
@ -75,6 +77,7 @@ def _nh3_attributes() -> dict[str, set[str]]: @@ -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: @@ -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 ``<img src="https://…">``; 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: @@ -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 = @@ -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"<br\s*/?>", "\n", frag, flags=re.I)
frag = re.sub(r"</p\s*>", "\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;} @@ -198,9 +243,17 @@ img{max-width:min(100%,400px);height:auto;}
</style>"""
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 (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{_PREVIEW_CSS}</head><body>{inner}</body></html>"

97
src/imwald/core/nip30_emoji.py

@ -0,0 +1,97 @@ @@ -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:<hex64>:<d-identifier>`` (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'<img src="{esc_u}" alt="{esc_alt}" width="20" height="20" '
'class="nip30-emoji" style="vertical-align:middle;max-height:1.35em;width:auto">'
)
return _SHORTCODE.sub(repl, segment)
def preprocess_nip30_emoji_markdown(md: str, url_by_shortcode: dict[str, str]) -> str:
"""
Replace ``:shortcode:`` with inline ``<img>`` (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)

10
src/imwald/ui/composer_dialog.py

@ -60,7 +60,15 @@ class ComposerDialog(QDialog): @@ -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(

60
src/imwald/ui/feed_page.py

@ -4,6 +4,7 @@ from __future__ import annotations @@ -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 @@ -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: @@ -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: @@ -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'<img src="{esc_u}" alt="{esc_alt}" width="20" height="20" '
'style="vertical-align:middle;max-height:1.35em;width:auto" />'
)
else:
e = html.escape(raw_disp, quote=False)
else:
e = html.escape(raw_disp, quote=False)
if c > 1:
emoji_bits.append(f'<span style="font-size:21px" title="{e}×{c}">{e}<sub style="font-size:13px">{c}</sub></span>')
emoji_bits.append(
f'<span style="font-size:21px" title="{esc_title}×{c}">{e}'
f'<sub style="font-size:13px">{c}</sub></span>'
)
else:
emoji_bits.append(f'<span style="font-size:21px">{e}</span>')
em_row = " &nbsp; ".join(emoji_bits) if emoji_bits else ""
@ -323,9 +355,13 @@ class FeedPage(QWidget): @@ -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): @@ -354,7 +390,12 @@ class FeedPage(QWidget):
tr = f"<div style='color:{TEXT_DIM};font-size:15px;margin:6px 0'><i>Trending slice (nostrarchives)</i></div>"
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 = (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body>"
@ -375,7 +416,12 @@ class FeedPage(QWidget): @@ -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")

10
src/imwald/ui/main_window.py

@ -266,7 +266,15 @@ class MainWindow(QMainWindow): @@ -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)

20
src/imwald/ui/markdown_editor_widget.py

@ -13,9 +13,18 @@ from imwald.core.md_render import markdown_html_document @@ -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): @@ -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)

28
src/imwald/ui/notifications_page.py

@ -2,6 +2,9 @@ @@ -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): @@ -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): @@ -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)

12
src/imwald/ui/search_page.py

@ -2,6 +2,8 @@ @@ -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): @@ -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"])

104
tests/test_nip30_emoji.py

@ -0,0 +1,104 @@ @@ -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 '<img src="https://example.com/x.png"' in out
def test_markdown_html_fragment_nip30() -> 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 ``<img>`` 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
Loading…
Cancel
Save