diff --git a/src/imwald/core/author_html.py b/src/imwald/core/author_html.py
index f97bbb8..1903bac 100644
--- a/src/imwald/core/author_html.py
+++ b/src/imwald/core/author_html.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import html
from imwald.core.kind0_profile import display_name_from_profile, display_name_from_profile_or_hex
+from imwald.core.nip05 import favicon_url_for_domain, parse_nip05_identifier
def safe_http_url(u: str | None) -> str | None:
@@ -50,38 +51,78 @@ def avatar_img_or_placeholder(
)
-def feed_op_author_block_html(
+def format_nip05_chips_html(
+ identifiers: list[tuple[str, bool | None]],
+ *,
+ muted: str,
+ dim: str,
+ ok: str,
+ bad: str,
+) -> str:
+ """
+ Horizontal row of NIP-05 chips: favicon (per domain), identifier, verification mark.
+
+ ``bool | None``: ``True`` verified, ``False`` failed, ``None`` pending / not checked.
+ """
+ parts: list[str] = []
+ for ident, okv in identifiers:
+ parsed = parse_nip05_identifier(ident)
+ dom = parsed[1] if parsed else ""
+ fav = favicon_url_for_domain(dom) if dom else ""
+ esc = html.escape(ident, quote=False)
+ esc_fav = html.escape(fav, quote=True) if fav else ""
+ icon = ""
+ if fav:
+ icon = (
+ f'
'
+ )
+ if okv is True:
+ mark = f'✓'
+ elif okv is False:
+ mark = f'✗'
+ else:
+ mark = f'○'
+ parts.append(
+ f''
+ f"{icon}"
+ f'{esc}{mark}'
+ f""
+ )
+ if not parts:
+ return ""
+ return f'
{"".join(parts)}
'
+
+
+def feed_op_author_compact_html(
parsed: dict[str, str | None],
npub_bech: str,
- pk_short: str,
- nip_line_html: str,
- about_line_html: str,
- *,
pubkey_hex: str,
- text: str,
+ *,
+ status_inner_html: str,
+ nip05_chips_html: str,
muted: str,
- dim: str,
border: str,
) -> str:
- """Top-of-note author row: picture, display name, npub, optional nip05/about lines (links to profile tab)."""
- disp = html.escape(display_name_from_profile(parsed))
+ """Feed OP header: avatar, npub only, optional NIP-38 status (HTML), NIP-05 chips."""
pk_l = pubkey_hex.strip().lower()
href = html.escape(f"imwald://pub/{pk_l}", quote=True)
av = avatar_img_or_placeholder(parsed, 52, border_hex=border, profile_href=href)
npub_e = html.escape(npub_bech)
- pk_s = html.escape(pk_short)
- inner = (
- f''
+ status_block = ""
+ if status_inner_html.strip():
+ status_block = f'
{status_inner_html}
'
+ return (
+ f'
"
)
- return inner
def thread_reply_author_row_html(
diff --git a/src/imwald/core/forest_avatar.py b/src/imwald/core/forest_avatar.py
index 2e3178a..b9d5a84 100644
--- a/src/imwald/core/forest_avatar.py
+++ b/src/imwald/core/forest_avatar.py
@@ -6,11 +6,11 @@ import io
import random
-def build_forest_avatar_png(*, size: int = 192) -> bytes:
+def build_forest_avatar_png(*, size: int = 192, seed: int = 42) -> bytes:
"""Return small PNG bytes: low resolution, palette, max zlib — keeps nostr.build uploads tiny."""
from PIL import Image, ImageDraw # type: ignore[import-not-found, import-untyped]
- rng = random.Random(42)
+ rng = random.Random(int(seed) % (2**31))
w = h = max(64, min(size, 256))
img = Image.new("RGB", (w, h), (22, 68, 42))
dr = ImageDraw.Draw(img)
@@ -46,3 +46,22 @@ def build_forest_avatar_png(*, size: int = 192) -> bytes:
buf = io.BytesIO()
paletted.save(buf, format="PNG", compress_level=9, optimize=True)
return buf.getvalue()
+
+
+def build_forest_banner_png(*, width: int = 1600, height: int = 260, seed: int = 0) -> bytes:
+ """
+ Wide forest-themed PNG for profile headers when the user has no ``banner`` URL.
+
+ Built from the same generative style as :func:`build_forest_avatar_png`, stretched to banner aspect.
+ """
+ from PIL import Image # type: ignore[import-not-found, import-untyped]
+
+ w = max(480, min(int(width), 2400))
+ h = max(96, min(int(height), 480))
+ sq = max(256, min(h * 2, 640))
+ raw = build_forest_avatar_png(size=sq, seed=seed)
+ im = Image.open(io.BytesIO(raw)).convert("RGB")
+ im = im.resize((w, h), Image.Resampling.LANCZOS)
+ buf = io.BytesIO()
+ im.save(buf, format="PNG", compress_level=9, optimize=True)
+ return buf.getvalue()
diff --git a/src/imwald/core/kind0_profile.py b/src/imwald/core/kind0_profile.py
index ef1872d..9114827 100644
--- a/src/imwald/core/kind0_profile.py
+++ b/src/imwald/core/kind0_profile.py
@@ -5,6 +5,8 @@ from __future__ import annotations
import json
from typing import cast
+from imwald.core.nip05 import scan_nip05_like_strings
+
def parse_kind0_profile(content: str) -> dict[str, str | None]:
"""Return display fields from kind-0 ``content`` JSON (best-effort)."""
@@ -48,6 +50,59 @@ def display_name_from_profile(p: dict[str, str | None]) -> str:
return (p.get("display_name") or p.get("name") or "").strip() or "anon"
+def kind0_json_object_prefix(content: str) -> str:
+ """If ``content`` starts with JSON then has trailing text, return only the first ``{…}`` object."""
+ s = (content or "").strip()
+ if not s or not s.lstrip().startswith("{"):
+ return s
+ depth = 0
+ start = s.find("{")
+ if start < 0:
+ return s
+ for i in range(start, len(s)):
+ c = s[i]
+ if c == "{":
+ depth += 1
+ elif c == "}":
+ depth -= 1
+ if depth == 0:
+ return s[start : i + 1]
+ return s
+
+
+def collect_nip05_identifiers(kind0_content: str, kind0_tags: list[list[str]] | None) -> list[str]:
+ """
+ All plausible NIP-05 identifiers for an author: JSON ``nip05``, ``nip05`` / ``nip-05`` tags,
+ and ``user@host``-shaped strings in the raw kind-0 ``content`` (deduped, order preserved).
+ """
+ seen: set[str] = set()
+ out: list[str] = []
+
+ def add(s: str | None) -> None:
+ if not s or not isinstance(s, str):
+ return
+ t = s.strip()
+ if not t or "@" not in t:
+ return
+ k = t.lower()
+ if k in seen:
+ return
+ seen.add(k)
+ out.append(t)
+
+ p = parse_kind0_profile(kind0_json_object_prefix(kind0_content or ""))
+ add(p.get("nip05"))
+ for t in kind0_tags or []:
+ if len(t) < 2:
+ continue
+ name = str(t[0]).lower()
+ if name in ("nip05", "nip-05"):
+ add(str(t[1]))
+ for s in scan_nip05_like_strings(kind0_content or ""):
+ add(s)
+ return out
+
+
def display_name_from_profile_or_hex(p: dict[str, str | None], pubkey_hex: str) -> str:
"""Like ``display_name_from_profile`` but falls back to a short hex id when no name is set."""
n = (p.get("display_name") or p.get("name") or "").strip()
diff --git a/src/imwald/core/nip05.py b/src/imwald/core/nip05.py
new file mode 100644
index 0000000..fd4b7ef
--- /dev/null
+++ b/src/imwald/core/nip05.py
@@ -0,0 +1,90 @@
+"""NIP-05 identifier parsing and HTTPS ``/.well-known/nostr.json`` verification (Jumble-style)."""
+
+from __future__ import annotations
+
+import json
+import re
+import urllib.error
+import urllib.parse
+import urllib.request
+from typing import Final
+
+_USER_AGENT: Final = "imwald/0.1 (NIP-05; +https://github.com/nostr-protocol/nostr)"
+
+# Loose identifier: local@domain with plausible host (NIP-05 shape).
+_NIP05_LIKE: Final = re.compile(
+ r"\b([a-z0-9._+-]+)@([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)\b",
+ re.IGNORECASE,
+)
+
+
+def parse_nip05_identifier(raw: str) -> tuple[str, str] | None:
+ """
+ Split ``local@domain`` into parts suitable for NIP-05 well-known lookup.
+
+ Returns ``(local, domain_lower)`` or ``None`` if the string is not a plausible NIP-05 id.
+ """
+ s = (raw or "").strip()
+ if not s or "@" not in s:
+ return None
+ local, _, host = s.rpartition("@")
+ local = local.strip()
+ host = host.strip().lower()
+ if not local or not host or "." not in host:
+ return None
+ if any(c in local for c in (" ", "\t", "\n", "/", "\\")):
+ return None
+ return local, host
+
+
+def favicon_url_for_domain(domain: str) -> str:
+ """Small favicon URL for UI chips (same pattern many Nostr clients use)."""
+ d = domain.strip().lower()
+ return f"https://www.google.com/s2/favicons?sz=16&domain={urllib.parse.quote(d, safe='')}"
+
+
+def verify_nip05(pubkey_hex: str, nip05_identifier: str, *, timeout: float = 10.0) -> bool:
+ """
+ Return whether ``nip05_identifier`` maps to ``pubkey_hex`` via
+ ``https:///.well-known/nostr.json?name=`` (NIP-05).
+ """
+ parsed = parse_nip05_identifier(nip05_identifier)
+ if not parsed:
+ return False
+ local, domain = parsed
+ pk = pubkey_hex.strip().lower()
+ if len(pk) != 64 or any(c not in "0123456789abcdef" for c in pk):
+ return False
+ url = f"https://{domain}/.well-known/nostr.json?name={urllib.parse.quote(local, safe='')}"
+ try:
+ req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}, method="GET")
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
+ raw = resp.read(256_000)
+ data = json.loads(raw.decode("utf-8", errors="replace"))
+ except (OSError, UnicodeError, json.JSONDecodeError, urllib.error.URLError):
+ return False
+ if not isinstance(data, dict):
+ return False
+ names = data.get("names")
+ if not isinstance(names, dict):
+ return False
+ for key, val in names.items():
+ if str(key).lower() == local.lower() and isinstance(val, str):
+ got = val.strip().lower()
+ if len(got) == 64 and all(c in "0123456789abcdef" for c in got):
+ return got == pk
+ return False
+
+
+def scan_nip05_like_strings(text: str) -> list[str]:
+ """Return ordered unique substrings in ``text`` that look like ``user@host.tld``."""
+ seen: set[str] = set()
+ out: list[str] = []
+ for m in _NIP05_LIKE.finditer(text or ""):
+ s = m.group(0).strip()
+ key = s.lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ out.append(s)
+ return out
diff --git a/src/imwald/core/nip38_status.py b/src/imwald/core/nip38_status.py
new file mode 100644
index 0000000..13d933f
--- /dev/null
+++ b/src/imwald/core/nip38_status.py
@@ -0,0 +1,96 @@
+"""NIP-38 (kind 30315) user status: pick latest non-expired status event from the local DB."""
+
+from __future__ import annotations
+
+import json
+import time
+from typing import Any, cast
+
+from imwald.core.database import Database
+
+
+def _expiration_ts(tags: list[list[str]]) -> int | None:
+ for t in tags:
+ if len(t) >= 2 and str(t[0]).lower() == "expiration":
+ try:
+ return int(str(t[1]).strip())
+ except ValueError:
+ return None
+ return None
+
+
+def _first_link_tag(tags: list[list[str]]) -> str | None:
+ for t in tags:
+ if len(t) >= 2 and str(t[0]).lower() == "r":
+ u = str(t[1]).strip()
+ if u.startswith("https://") or u.startswith("http://"):
+ return u
+ return None
+
+
+def get_active_user_status_event(db: Database, pubkey_hex: str) -> dict[str, Any] | None:
+ """
+ Latest **non-expired** kind ``30315`` for ``pubkey_hex`` with non-empty ``content``.
+
+ Prefers ``d`` tag ``general``, then ``music``, then any other ``d`` with a body (newest first).
+ """
+ pk = pubkey_hex.strip().lower()
+ if len(pk) != 64 or any(c not in "0123456789abcdef" for c in pk):
+ return None
+ now = int(time.time())
+ for d in ("general", "music"):
+ ev = db.get_event_by_addressable(30315, pk, d)
+ if not ev or ev.get("deleted"):
+ continue
+ tags_raw = ev.get("tags")
+ tags = cast(list[list[str]], tags_raw) if isinstance(tags_raw, list) else []
+ exp = _expiration_ts(tags)
+ if exp is not None and exp <= now:
+ continue
+ if not str(ev.get("content") or "").strip():
+ continue
+ return ev
+ # Other custom ``d`` values: newest 30315 by this author with content + not expired.
+ cur = db.conn().execute(
+ """
+ SELECT id, pubkey, created_at, kind, content, sig, tags_json, deleted, source_relay
+ FROM events
+ WHERE deleted = 0 AND kind = 30315 AND lower(pubkey) = lower(?)
+ ORDER BY created_at DESC
+ LIMIT 24
+ """,
+ (pk,),
+ )
+ for row in cur.fetchall():
+ tags = cast(list[list[str]], json.loads(row["tags_json"] or "[]"))
+ d_val = ""
+ for t in tags:
+ if len(t) >= 2 and str(t[0]).lower() == "d":
+ d_val = str(t[1] or "")
+ break
+ if d_val in ("general", "music"):
+ continue
+ exp = _expiration_ts(tags)
+ if exp is not None and exp <= now:
+ continue
+ if not str(row["content"] or "").strip():
+ continue
+ return {
+ "id": row["id"],
+ "pubkey": row["pubkey"],
+ "created_at": int(row["created_at"]),
+ "kind": int(row["kind"]),
+ "content": row["content"],
+ "sig": row["sig"],
+ "tags": tags,
+ "deleted": bool(row["deleted"]),
+ "source_relay": row["source_relay"],
+ }
+ return None
+
+
+def status_link_from_event(ev: dict[str, Any]) -> str | None:
+ """First ``r`` tag with ``http(s)`` URL on a status event, if any."""
+ tags_raw = ev.get("tags")
+ tags = cast(list[list[str]], tags_raw) if isinstance(tags_raw, list) else []
+ return _first_link_tag(tags)
diff --git a/src/imwald/core/nostr_engine.py b/src/imwald/core/nostr_engine.py
index 0a3f69b..7126a6f 100644
--- a/src/imwald/core/nostr_engine.py
+++ b/src/imwald/core/nostr_engine.py
@@ -33,7 +33,7 @@ from imwald.core.relay_policy import (
log = logging.getLogger(__name__)
# Per-author backfill: profile + lists + NIP-30 inventory (Jumble-style).
-AUTHOR_METADATA_KINDS: tuple[int, ...] = (0, 10015, 30000, 10030, 30030)
+AUTHOR_METADATA_KINDS: tuple[int, ...] = (0, 10015, 30000, 10030, 30030, 30315)
_AUTHOR_META_SUB_ID = "imwald-ameta"
diff --git a/src/imwald/ui/feed_page.py b/src/imwald/ui/feed_page.py
index b23dfe6..0e11025 100644
--- a/src/imwald/ui/feed_page.py
+++ b/src/imwald/ui/feed_page.py
@@ -8,7 +8,7 @@ import re
from collections.abc import Sequence
from typing import Any, cast
-from PySide6.QtCore import QEvent, QObject, Qt, QTimer, Signal, QUrl
+from PySide6.QtCore import QEvent, QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, QUrl
from PySide6.QtGui import QDesktopServices, QKeyEvent, QTextOption
from PySide6.QtWidgets import (
@@ -24,16 +24,26 @@ from PySide6.QtWidgets import (
QWidget,
)
-from imwald.core.author_html import feed_op_author_block_html, thread_reply_author_row_html
+from imwald.core.author_html import (
+ feed_op_author_compact_html,
+ format_nip05_chips_html,
+ thread_reply_author_row_html,
+)
from imwald.core.database import Database
from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX
-from imwald.core.kind0_profile import parse_kind0_profile
+from imwald.core.kind0_profile import (
+ collect_nip05_identifiers,
+ kind0_json_object_prefix,
+ parse_kind0_profile,
+)
+from imwald.core.nip38_status import get_active_user_status_event
from imwald.core.md_render import markdown_html_fragment, markdown_to_plain_text
from imwald.core.nip19 import encode_npub
from imwald.core.nostr_engine import NostrEngine
from imwald.core.ranker import Ranker
+from imwald.ui.media_viewer_dialog import try_open_media_url_in_app
from imwald.ui.note_text_browser import NoteTextBrowser
-from imwald.ui.theme import BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED
+from imwald.ui.theme import ACCENT, BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED
FEED_KINDS = (1, 20, 21, 30023, 9802, 11)
@@ -46,6 +56,25 @@ def _nip30_tags(ev_row: dict[str, Any]) -> list[list[str]] | None:
return cast(list[list[str]], t) if isinstance(t, list) else None
+class _Nip05VerifySignals(QObject):
+ finished = Signal(int, object)
+
+
+class _Nip05VerifyRunnable(QRunnable):
+ def __init__(self, pk: str, cands: list[str], gen: int, out: _Nip05VerifySignals) -> None:
+ super().__init__()
+ self._pk = pk
+ self._cands = cands
+ self._gen = gen
+ self._out = out
+
+ def run(self) -> None:
+ from imwald.core.nip05 import verify_nip05
+
+ pairs: list[tuple[str, bool]] = [(c, verify_nip05(self._pk, c, timeout=9.0)) for c in self._cands]
+ self._out.finished.emit(self._gen, pairs)
+
+
def _set_plain_height_to_content(te: QPlainTextEdit) -> None:
doc = te.document()
lay = doc.documentLayout()
@@ -158,6 +187,12 @@ class FeedPage(QWidget):
self._rendered_op_id: str | None = None
self._rendered_reply_sig: tuple[str, ...] | None = None
self._thread_card_by_eid: dict[str, QFrame] = {}
+ self._op_ev_snapshot: dict[str, Any] | None = None
+ self._op_nip05_gen = 0
+ self._nip05_sigs = _Nip05VerifySignals(self)
+ self._nip05_sigs.finished.connect(self._on_nip05_verify_done)
+ self._nip05_pool = QThreadPool(self)
+ self._nip05_pool.setMaxThreadCount(2)
self._engagement = QFrame()
self._engagement.setObjectName("EngagementBar")
@@ -264,6 +299,8 @@ class FeedPage(QWidget):
return
s = url.toString()
if s.startswith("https://") or s.startswith("http://"):
+ if try_open_media_url_in_app(self.window(), url):
+ return
QDesktopServices.openUrl(url)
def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802
@@ -361,6 +398,7 @@ class FeedPage(QWidget):
def show_event(self, event_id: str) -> None:
ev = self._db.get_event(event_id)
if not ev:
+ self._op_ev_snapshot = None
self._op.setPlainText(f"(not in local DB yet) {event_id}")
return
self._queue = [cast(dict[str, Any], ev)]
@@ -369,6 +407,77 @@ class FeedPage(QWidget):
if not ev.get("deleted"):
self._db.mark_feed_viewed(self._feed_viewer_key(), ev["id"])
+ def _build_op_html(self, ev: dict[str, Any], nip05_verified: list[tuple[str, bool]] | None) -> str:
+ """Full OP ``QTextBrowser`` document: compact author row + note body."""
+ pk = str(ev["pubkey"])
+ prof_row = self._db.get_latest_kind0_profile(pk)
+ k0_ev = self._db.get_latest_kind0_event(pk)
+ content = (k0_ev.get("content") if k0_ev else None) or (prof_row["content"] if prof_row else "") or "{}"
+ tags_l: list[list[str]] = k0_ev["tags"] if k0_ev else []
+ raw_prof = prof_row["content"] if prof_row else content
+ parsed = parse_kind0_profile(kind0_json_object_prefix(raw_prof))
+ npub = encode_npub(pk)
+ candidates = collect_nip05_identifiers(content, tags_l)
+ if nip05_verified is None:
+ states: list[tuple[str, bool | None]] = [(c, None) for c in candidates]
+ else:
+ vmap = {a.strip().lower(): b for a, b in nip05_verified}
+ states = [(c, vmap.get(c.strip().lower(), False)) for c in candidates]
+ chips = format_nip05_chips_html(
+ states, muted=TEXT_MUTED, dim=TEXT_DIM, ok=ACCENT, bad="#b86a6a"
+ )
+ st_ev = get_active_user_status_event(self._db, pk)
+ st_html = ""
+ if st_ev:
+ st_html = markdown_html_fragment(
+ st_ev.get("content") or "",
+ db=self._db,
+ nip30_tags=_nip30_tags(st_ev),
+ nip30_author_pubkey=pk,
+ )
+ author_block = feed_op_author_compact_html(
+ parsed,
+ npub,
+ pk,
+ status_inner_html=st_html,
+ nip05_chips_html=chips,
+ muted=TEXT_MUTED,
+ border=BORDER,
+ )
+ tr = ""
+ sr = ev.get("source_relay") or ""
+ if sr and "nostrarchives.com" in sr:
+ tr = f"Trending slice (nostrarchives)
"
+ eid = html.escape(str(ev["id"]))
+ md_body = markdown_html_fragment(
+ ev.get("content") or "",
+ db=self._db,
+ nip30_tags=_nip30_tags(ev),
+ nip30_author_pubkey=pk,
+ )
+ return (
+ ""
+ f"{FEED_DOC_CSS}"
+ f"{author_block}"
+ f"Kind {int(ev['kind'])} · {int(ev['created_at'])}
"
+ f"{tr}"
+ f"{md_body}
"
+ f"{eid}
"
+ ""
+ )
+
+ def _on_nip05_verify_done(self, gen: int, pairs_obj: object) -> None:
+ if gen != self._op_nip05_gen:
+ return
+ ev = self._op_ev_snapshot
+ op_id = self._rendered_op_id
+ if not ev or not op_id or str(ev.get("id")) != op_id:
+ return
+ if not isinstance(pairs_obj, list):
+ return
+ pairs = cast(list[tuple[str, bool]], pairs_obj)
+ self._op.setHtml(self._build_op_html(ev, pairs))
+
def _clear_thread_rows(self) -> None:
self._thread_card_by_eid.clear()
while self._thread_layout.count():
@@ -385,6 +494,7 @@ class FeedPage(QWidget):
if not self._queue:
self._rendered_op_id = None
self._rendered_reply_sig = None
+ self._op_ev_snapshot = None
self._op.setPlainText("No events in local database yet — wait for relay sync.")
self._clear_thread_rows()
self._why.setText("")
@@ -394,6 +504,7 @@ class FeedPage(QWidget):
if ev.get("deleted"):
self._rendered_op_id = None
self._rendered_reply_sig = None
+ self._op_ev_snapshot = None
raw = html.escape(ev.get("content") or "")
self._op.setHtml(
f""
@@ -431,54 +542,20 @@ class FeedPage(QWidget):
self._rendered_op_id = root_id
self._rendered_reply_sig = reply_sig
- 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)
- pk_short = pk[:12] + "…"
- nip05 = html.escape((parsed.get("nip05") or "").strip()) if parsed.get("nip05") else ""
- about = html.escape((parsed.get("about") or "")[:280]) if parsed.get("about") else ""
- nip_line = (
- f"{nip05}
" if nip05 else ""
- )
- about_line = f"{about}
" if about else ""
- author_block = feed_op_author_block_html(
- parsed,
- npub,
- pk_short,
- nip_line,
- about_line,
- pubkey_hex=pk,
- text=TEXT,
- muted=TEXT_MUTED,
- dim=TEXT_DIM,
- border=BORDER,
- )
-
- tr = ""
- sr = ev.get("source_relay") or ""
- if sr and "nostrarchives.com" in sr:
- tr = f"Trending slice (nostrarchives)
"
-
- eid = html.escape(ev["id"])
- 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}"
- f"{author_block}"
- f"Kind {int(ev['kind'])} · {int(ev['created_at'])}
"
- f"{tr}"
- f"{md_body}
"
- f"{eid}
"
- ""
- )
+ self._op_ev_snapshot = ev
+ body = self._build_op_html(ev, None)
self._op.setHtml(body)
+ pk = op_pk
+ prof_row2 = self._db.get_latest_kind0_profile(pk)
+ k0_ev2 = self._db.get_latest_kind0_event(pk)
+ content2 = (k0_ev2.get("content") if k0_ev2 else None) or (prof_row2["content"] if prof_row2 else "") or "{}"
+ tags_l2: list[list[str]] = k0_ev2["tags"] if k0_ev2 else []
+ candidates = collect_nip05_identifiers(content2, tags_l2)
+ if candidates:
+ self._op_nip05_gen += 1
+ self._nip05_pool.start(_Nip05VerifyRunnable(pk, candidates, self._op_nip05_gen, self._nip05_sigs))
+
self._thread_scroll.setUpdatesEnabled(False)
try:
self._clear_thread_rows()
diff --git a/src/imwald/ui/main_window.py b/src/imwald/ui/main_window.py
index 614034b..7329bc2 100644
--- a/src/imwald/ui/main_window.py
+++ b/src/imwald/ui/main_window.py
@@ -301,8 +301,8 @@ class MainWindow(QMainWindow):
def _flush_ingest_ui_refresh(self) -> None:
if self._stack.currentIndex() == 0:
cur = self._browser_tabs.currentWidget()
- if cur is self._feed:
- self._feed.refresh_tail()
+ if isinstance(cur, FeedPage):
+ cur.refresh_tail()
elif isinstance(cur, ProfilePage):
cur.refresh()
self._notif.refresh_all()
@@ -432,7 +432,7 @@ class MainWindow(QMainWindow):
existing.refresh()
return
page = ProfilePage(self._db, self._engine, pk, self._browser_tabs)
- page.open_note.connect(self._open_event)
+ page.open_note_new_tab.connect(self._open_event_in_new_tab)
page.open_profile.connect(self._open_profile_tab)
self._profile_tabs_by_pubkey[pk] = page
self._browser_tabs.addTab(page, page.tab_title())
@@ -448,6 +448,7 @@ class MainWindow(QMainWindow):
if v is w:
del self._profile_tabs_by_pubkey[k]
break
+ if w is not None:
w.deleteLater()
def _wire_pages(self) -> None:
@@ -465,6 +466,46 @@ class MainWindow(QMainWindow):
self._go_stack_page(0)
self._feed.show_event(event_id)
+ def _open_event_in_new_tab(self, event_id: str) -> None:
+ eid = event_id.strip().lower()
+ if len(eid) != 64 or any(c not in "0123456789abcdef" for c in eid):
+ return
+ self._go_stack_page(0)
+ fp = FeedPage(self._db, self._engine)
+ pk = self._current_pubkey()
+ following: set[str] = set()
+ list300: set[str] = set()
+ if pk:
+ following = self._db.list_following_pubkeys(pk)
+ list300 = self._db.list_kind30000_list_pubkeys(pk)
+ fp.set_context(pk, following, list300)
+ fp.profile_requested.connect(self._open_profile_tab)
+ fp.show_event(eid)
+ self._browser_tabs.addTab(fp, self._event_view_tab_title(eid))
+ self._browser_tabs.setCurrentWidget(fp)
+
+ def _event_view_tab_title(self, event_id: str) -> str:
+ ev = self._db.get_event(event_id)
+ if not ev:
+ return f"Note {event_id[:10]}…"
+ nip30_tags = ev["tags"]
+ pk_ev = str(ev.get("pubkey") or "").strip().lower() or None
+ summ = markdown_plain_summary(
+ ev.get("content") or "",
+ max_len=40,
+ db=self._db,
+ nip30_tags=nip30_tags,
+ nip30_author_pubkey=pk_ev,
+ )
+ if summ.strip():
+ t = summ.strip().replace("\n", " ")
+ return (t[:30] + "…") if len(t) > 30 else t
+ try:
+ k = int(ev.get("kind", 0))
+ except (TypeError, ValueError):
+ k = 0
+ return f"k{k} · {event_id[:10]}…"
+
def _nip09_from_db(self, event_id: str, pubkey: str) -> None:
acc = next((a for a in self._accounts if a.pubkey.lower() == pubkey.lower()), None)
if not acc:
diff --git a/src/imwald/ui/media_viewer_dialog.py b/src/imwald/ui/media_viewer_dialog.py
new file mode 100644
index 0000000..015dc8e
--- /dev/null
+++ b/src/imwald/ui/media_viewer_dialog.py
@@ -0,0 +1,376 @@
+"""In-app viewer for remote images, audio, and video (HTTP/S) opened from note links."""
+
+from __future__ import annotations
+
+import urllib.request
+from typing import Final, Literal
+
+from PySide6.QtCore import QByteArray, QObject, QRunnable, Qt, QThreadPool, QUrl, Signal
+from PySide6.QtGui import (
+ QClipboard,
+ QCloseEvent,
+ QGuiApplication,
+ QImage,
+ QKeySequence,
+ QPixmap,
+ QResizeEvent,
+ QShortcut,
+ QWheelEvent,
+)
+from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
+from PySide6.QtMultimediaWidgets import QVideoWidget
+from PySide6.QtSvgWidgets import QSvgWidget
+from PySide6.QtWidgets import (
+ QDialog,
+ QDialogButtonBox,
+ QHBoxLayout,
+ QLabel,
+ QMessageBox,
+ QPushButton,
+ QScrollArea,
+ QSizePolicy,
+ QSlider,
+ QStyle,
+ QVBoxLayout,
+ QWidget,
+)
+
+from imwald.ui.theme import ACCENT, BG_CARD, BG_FIELD, BG_WINDOW, BORDER, TEXT, TEXT_DIM, TEXT_MUTED
+
+_MAX_BYTES: Final = 8 * 1024 * 1024
+_USER_AGENT: Final = "imwald/0.1 (PySide6; +https://github.com/nostr-protocol/nostr)"
+
+_IMAGE_EXT: Final = frozenset(
+ {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp", ".tif", ".tiff", ".svg"}
+)
+_AUDIO_EXT: Final = frozenset({".mp3", ".ogg", ".opus", ".wav", ".flac", ".m4a", ".aac", ".oga"})
+_VIDEO_EXT: Final = frozenset({".mp4", ".webm", ".mov", ".mkv", ".ogv", ".m4v", ".avi", ".wmv"})
+
+
+def classify_media_url(url: QUrl) -> Literal["image", "audio", "video"] | None:
+ """Best-effort media kind from URL path (query stripped). Unknown → ``None`` (open externally)."""
+ if url.scheme() not in ("http", "https"):
+ return None
+ path = url.path().lower()
+ if not path:
+ return None
+ dot = path.rfind(".")
+ ext = path[dot:] if dot >= 0 else ""
+ if ext in _IMAGE_EXT:
+ return "image"
+ if ext in _AUDIO_EXT:
+ return "audio"
+ if ext in _VIDEO_EXT:
+ return "video"
+ return None
+
+
+class _FetchSignals(QObject):
+ ok = Signal(bytes)
+ fail = Signal(str)
+
+
+class _FetchRunnable(QRunnable):
+ def __init__(self, url: str, sigs: _FetchSignals) -> None:
+ super().__init__()
+ self._url = url
+ self._sigs = sigs
+
+ def run(self) -> None:
+ try:
+ req = urllib.request.Request(self._url, headers={"User-Agent": _USER_AGENT}, method="GET")
+ with urllib.request.urlopen(req, timeout=45) as resp: # noqa: S310
+ data = resp.read(_MAX_BYTES + 1)
+ if len(data) > _MAX_BYTES:
+ self._sigs.fail.emit("File is larger than the in-app viewer limit (8 MiB).")
+ return
+ self._sigs.ok.emit(data)
+ except OSError as e:
+ self._sigs.fail.emit(str(e) or "Download failed.")
+
+
+class _ImageCanvas(QWidget):
+ """Scrollable image with Ctrl+wheel zoom (keeps a crisp source pixmap)."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._src = QPixmap()
+ self._zoom = 1.0
+ self._label = QLabel()
+ self._label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self._label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
+ self._scroll = QScrollArea(self)
+ self._scroll.setWidget(self._label)
+ self._scroll.setWidgetResizable(True)
+ self._scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self._scroll.setFrameShape(QScrollArea.Shape.NoFrame)
+ lay = QVBoxLayout(self)
+ lay.setContentsMargins(0, 0, 0, 0)
+ lay.addWidget(self._scroll)
+
+ def set_pixmap(self, pm: QPixmap) -> None:
+ self._src = pm
+ self._zoom = 1.0
+ self._apply()
+
+ def wheelEvent(self, event: QWheelEvent) -> None:
+ if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
+ delta = 1.0 + (0.12 if event.angleDelta().y() > 0 else -0.12)
+ self._zoom = max(0.25, min(6.0, self._zoom * delta))
+ self._apply()
+ event.accept()
+ return
+ super().wheelEvent(event)
+
+ def resizeEvent(self, event: QResizeEvent) -> None:
+ super().resizeEvent(event)
+ self._apply()
+
+ def _apply(self) -> None:
+ if self._src.isNull():
+ self._label.clear()
+ return
+ vw = max(self._scroll.viewport().width() - 8, 120)
+ vh = max(self._scroll.viewport().height() - 8, 120)
+ tw = max(int(vw * self._zoom), 1)
+ th = max(int(vh * self._zoom), 1)
+ fitted = self._src.scaled(
+ tw,
+ th,
+ Qt.AspectRatioMode.KeepAspectRatio,
+ Qt.TransformationMode.SmoothTransformation,
+ )
+ self._label.setPixmap(fitted)
+
+
+class MediaViewerDialog(QDialog):
+ """
+ Non-modal dark viewer: raster/SVG images (async fetch + Ctrl-zoom), or Qt Multimedia for A/V.
+
+ Use :func:`try_open_media_url_in_app` from link handlers so normal web links still open externally.
+ """
+
+ def __init__(
+ self,
+ parent: QWidget | None,
+ url: QUrl,
+ kind: Literal["image", "audio", "video"],
+ ) -> None:
+ super().__init__(parent)
+ self.setWindowTitle(self._short_title(url))
+ self.setModal(False)
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
+ self.resize(920, 640)
+ self.setStyleSheet(
+ f"QDialog {{ background-color: {BG_WINDOW}; color: {TEXT}; }}"
+ f"QLabel {{ color: {TEXT}; }}"
+ f"QPushButton {{ background-color: {BG_CARD}; color: {TEXT}; border: 1px solid {BORDER}; "
+ f"padding: 6px 14px; border-radius: 8px; }}"
+ f"QPushButton:hover {{ border-color: {ACCENT}; color: {ACCENT}; }}"
+ f"QSlider::groove:horizontal {{ height: 6px; background: {BG_FIELD}; border-radius: 3px; }}"
+ f"QSlider::handle:horizontal {{ width: 14px; margin: -5px 0; background: {ACCENT}; border-radius: 7px; }}"
+ )
+ self._url = url
+ self._kind = kind
+ self._pool = QThreadPool(self)
+ self._pool.setMaxThreadCount(1)
+ self._player: QMediaPlayer | None = None
+ self._audio_out: QAudioOutput | None = None
+
+ root = QVBoxLayout(self)
+ root.setContentsMargins(12, 12, 12, 12)
+ root.setSpacing(10)
+
+ self._subtitle = QLabel(url.toString())
+ self._subtitle.setWordWrap(True)
+ self._subtitle.setStyleSheet(f"color:{TEXT_MUTED};font-size:13px")
+ root.addWidget(self._subtitle)
+ self._image_wait: QLabel | None = None
+
+ self._stack_host = QWidget()
+ self._stack_layout = QVBoxLayout(self._stack_host)
+ self._stack_layout.setContentsMargins(0, 0, 0, 0)
+ root.addWidget(self._stack_host, stretch=1)
+
+ btn_row = QHBoxLayout()
+ copy_b = QPushButton("Copy link")
+ copy_b.clicked.connect(self._copy_url)
+ ext_b = QPushButton("Open in browser")
+ ext_b.clicked.connect(self._open_external)
+ btn_row.addWidget(copy_b)
+ btn_row.addWidget(ext_b)
+ btn_row.addStretch()
+ close_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
+ close_box.rejected.connect(self.close)
+ close_box.accepted.connect(self.close)
+ btn_row.addWidget(close_box)
+ root.addLayout(btn_row)
+
+ esc = QShortcut(QKeySequence(Qt.Key.Key_Escape), self)
+ esc.activated.connect(self.close)
+
+ if kind == "image":
+ self._setup_image(url)
+ else:
+ self._setup_av(url, kind)
+
+ @staticmethod
+ def _short_title(url: QUrl) -> str:
+ s = url.toString()
+ return s if len(s) <= 56 else s[:26] + "…" + s[-24:]
+
+ def _copy_url(self) -> None:
+ QGuiApplication.clipboard().setText(self._url.toString(), QClipboard.Mode.Clipboard)
+
+ def _open_external(self) -> None:
+ from PySide6.QtGui import QDesktopServices
+
+ QDesktopServices.openUrl(self._url)
+
+ def _setup_image(self, url: QUrl) -> None:
+ self._image_wait = QLabel("Loading…")
+ self._image_wait.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self._image_wait.setStyleSheet(f"color:{TEXT_DIM};font-size:18px;padding:40px")
+ self._stack_layout.addWidget(self._image_wait)
+ sigs = _FetchSignals(self)
+ sigs.ok.connect(self._on_image_bytes)
+ sigs.fail.connect(self._on_image_fail)
+ self._pool.start(_FetchRunnable(url.toString(), sigs))
+
+ def _on_image_bytes(self, data: bytes) -> None:
+ w = self._image_wait
+ if w is not None:
+ self._stack_layout.removeWidget(w)
+ w.deleteLater()
+ self._image_wait = None
+ u = self._url.toString().lower()
+ if u.endswith(".svg") or data.lstrip().startswith((b"