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
from collections.abc import Generator, Iterable from collections.abc import Generator, Iterable
from typing import Any, TypedDict, cast from typing import Any, TypedDict, cast
from imwald.core.nip30_emoji import nip30_emoji_urls_from_tags, parse_kind30030_a_coordinate
SCHEMA_VERSION = 2 SCHEMA_VERSION = 2
@ -633,6 +635,114 @@ class Database:
out[pk] = {"content": row["content"] or "", "created_at": int(row["created_at"])} out[pk] = {"content": row["content"] or "", "created_at": int(row["created_at"])}
return out 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]: 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).""" """Counts from local DB: zaps (9735), reactions (7), boosts (6), quotes (``q`` on kind 1)."""
c = self.conn() c = self.conn()

71
src/imwald/core/md_render.py

@ -13,17 +13,19 @@ from typing import TYPE_CHECKING, cast
import nh3 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 from imwald.core.nostr_entity_render import preprocess_nostr_entities
if TYPE_CHECKING: if TYPE_CHECKING:
from imwald.core.database import Database
from quickjs import Context from quickjs import Context
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Bare HTTPS image URLs in notes → Markdown image (so renderer emits ``<img>``). # 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( _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, re.IGNORECASE,
) )
@ -75,6 +77,7 @@ def _nh3_attributes() -> dict[str, set[str]]:
img_a = raw.get("img") img_a = raw.get("img")
if img_a is not None: if img_a is not None:
img_a.add("style") img_a.add("style")
img_a.add("class")
_nh3_attrs_merged = dict(raw) _nh3_attrs_merged = dict(raw)
return _nh3_attrs_merged return _nh3_attrs_merged
@ -146,11 +149,35 @@ def preprocess_standalone_image_urls(md: str) -> str:
return _STANDALONE_IMAGE_URL.sub(repl, md or "") 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.""" """Sanitized HTML fragment (body inner HTML) for embedding in templates."""
# Standalone image URLs first: nostr preprocessing injects ``<img src="https://…">``; the # 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. # image-url pass must not run on that HTML or it corrupts attributes into Markdown images.
md = preprocess_standalone_image_urls(md or "") 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) md = preprocess_nostr_entities(md, db)
raw = _render_marked_js(md) raw = _render_marked_js(md)
if raw is None: if raw is None:
@ -158,14 +185,23 @@ def markdown_html_fragment(md: str, *, db: Database | None = None) -> str:
return _nh3_clean(raw) 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``, 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). 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. # Cap source length so list views (search, notifications, threads) do not parse huge notes.
src = (md or "")[:1200] 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 = html.unescape(re.sub(r"<[^>]+>", " ", frag))
plain = re.sub(r"\s+", " ", plain).strip() plain = re.sub(r"\s+", " ", plain).strip()
if len(plain) <= max_len: 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] + "" 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.""" """Full plain text from Markdown (for thread bodies); keeps paragraph breaks."""
src = (md or "")[:max_source] 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"<br\s*/?>", "\n", frag, flags=re.I)
frag = re.sub(r"</p\s*>", "\n\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) 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;}
</style>""" </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.""" """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 ( return (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">" "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{_PREVIEW_CSS}</head><body>{inner}</body></html>" f"{_PREVIEW_CSS}</head><body>{inner}</body></html>"

97
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:<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):
self._tags = QLineEdit() self._tags = QLineEdit()
self._tags.setPlaceholderText('JSON array of tags, e.g. [["t","nostr"]]') 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])) self._hint = QLabel("Suggestions: " + ", ".join(f'["{t}",""]' for t in TAG_SUGGESTIONS[:4]))
buttons = QDialogButtonBox( buttons = QDialogButtonBox(

60
src/imwald/ui/feed_page.py

@ -4,6 +4,7 @@ from __future__ import annotations
import html import html
import json import json
import re
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any, cast 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) 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: def _set_plain_height_to_content(te: QPlainTextEdit) -> None:
doc = te.document() doc = te.document()
@ -51,7 +60,11 @@ def _set_plain_height_to_content(te: QPlainTextEdit) -> None:
te.setFixedHeight(int(max(h + margins + fr, 44))) 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] = [] parts: list[str] = []
z = int(stats.get("zaps") or 0) z = int(stats.get("zaps") or 0)
b = int(stats.get("boosts") 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 c = int(str(c_o)) if str(c_o).isdigit() else 0
pairs.append((em, c)) pairs.append((em, c))
emoji_bits: list[str] = [] emoji_bits: list[str] = []
rx_urls = reaction_nip30_urls or {}
for em, c in pairs: 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: 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: else:
emoji_bits.append(f'<span style="font-size:21px">{e}</span>') emoji_bits.append(f'<span style="font-size:21px">{e}</span>')
em_row = " &nbsp; ".join(emoji_bits) if emoji_bits else "" em_row = " &nbsp; ".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)) self._why.setToolTip(json.dumps(why, ensure_ascii=False, indent=2))
stats = self._db.event_engagement_stats(ev["id"]) 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) prof_row = self._db.get_latest_kind0_profile(pk)
parsed = parse_kind0_profile(prof_row["content"] if prof_row else "") parsed = parse_kind0_profile(prof_row["content"] if prof_row else "")
npub = encode_npub(pk) npub = encode_npub(pk)
@ -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>" tr = f"<div style='color:{TEXT_DIM};font-size:15px;margin:6px 0'><i>Trending slice (nostrarchives)</i></div>"
eid = html.escape(ev["id"]) 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 = ( body = (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">" "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body>" f"{FEED_DOC_CSS}</head><body>"
@ -375,7 +416,12 @@ class FeedPage(QWidget):
rpk = str(r["pubkey"]).lower() rpk = str(r["pubkey"]).lower()
pr = profiles.get(rpk) pr = profiles.get(rpk)
rp = parse_kind0_profile(pr["content"] if pr else "") 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 = QFrame()
card.setObjectName("ReplyCard") card.setObjectName("ReplyCard")

10
src/imwald/ui/main_window.py

@ -266,7 +266,15 @@ class MainWindow(QMainWindow):
d.setWindowTitle("Your latest events") d.setWindowTitle("Your latest events")
lw = QListWidget() lw = QListWidget()
for ev in rows: 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 = QListWidgetItem(f"k{ev['kind']} {ev['id'][:16]}… — {snippet}")
it.setData(Qt.ItemDataRole.UserRole, ev["id"]) it.setData(Qt.ItemDataRole.UserRole, ev["id"])
lw.addItem(it) lw.addItem(it)

20
src/imwald/ui/markdown_editor_widget.py

@ -13,9 +13,18 @@ from imwald.core.md_render import markdown_html_document
class MarkdownBodyEditor(QWidget): class MarkdownBodyEditor(QWidget):
"""Plain-text Markdown editor with live rendered preview (local ``marked`` + nh3).""" """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) super().__init__(parent)
self._db = db self._db = db
self._nip30_tags = nip30_tags
self._nip30_author_pubkey = nip30_author_pubkey
self._split = QSplitter(Qt.Orientation.Horizontal) self._split = QSplitter(Qt.Orientation.Horizontal)
self._source = QPlainTextEdit() self._source = QPlainTextEdit()
self._source.setPlaceholderText("Markdown source — preview updates as you type") self._source.setPlaceholderText("Markdown source — preview updates as you type")
@ -54,7 +63,14 @@ class MarkdownBodyEditor(QWidget):
self._update_preview() self._update_preview()
def _update_preview(self) -> None: 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: def setPlainText(self, text: str) -> None:
self._source.setPlainText(text) self._source.setPlainText(text)

28
src/imwald/ui/notifications_page.py

@ -2,6 +2,9 @@
from __future__ import annotations from __future__ import annotations
import json
from typing import cast
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QLabel, QListWidget, QListWidgetItem, QTabWidget, QVBoxLayout, QWidget from PySide6.QtWidgets import QLabel, QListWidget, QListWidgetItem, QTabWidget, QVBoxLayout, QWidget
@ -59,7 +62,8 @@ class NotificationsPage(QWidget):
lw.clear() lw.clear()
cur = self._db.conn().execute( 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 FROM notifications n
LEFT JOIN events e ON e.id = n.source_event_id AND e.deleted = 0 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 WHERE n.recipient_pubkey=? ORDER BY n.created_at DESC LIMIT 200
@ -67,7 +71,27 @@ class NotificationsPage(QWidget):
(pubkey,), (pubkey,),
) )
for row in cur: 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 "" tail = f"{snippet}" if snippet else ""
title = f"{row['kind']} {row['source_event_id'][:12]}… read={row['read']}{tail}" title = f"{row['kind']} {row['source_event_id'][:12]}… read={row['read']}{tail}"
it = QListWidgetItem(title) it = QListWidgetItem(title)

12
src/imwald/ui/search_page.py

@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from typing import cast
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget
@ -36,7 +38,15 @@ class SearchPage(QWidget):
if not q: if not q:
return return
for ev in self._db.search_local(q, limit=200): 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}" title = f"{ev['kind']} {ev['id'][:12]}… — {snippet}"
it = QListWidgetItem(title) it = QListWidgetItem(title)
it.setData(Qt.ItemDataRole.UserRole, ev["id"]) it.setData(Qt.ItemDataRole.UserRole, ev["id"])

104
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 '<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