Browse Source

beautify the profile pages

master
Silberengel 2 weeks ago
parent
commit
a03f9d1fc7
  1. 79
      src/imwald/core/author_html.py
  2. 23
      src/imwald/core/forest_avatar.py
  3. 55
      src/imwald/core/kind0_profile.py
  4. 90
      src/imwald/core/nip05.py
  5. 96
      src/imwald/core/nip38_status.py
  6. 2
      src/imwald/core/nostr_engine.py
  7. 177
      src/imwald/ui/feed_page.py
  8. 47
      src/imwald/ui/main_window.py
  9. 376
      src/imwald/ui/media_viewer_dialog.py
  10. 526
      src/imwald/ui/profile_page.py
  11. 45
      src/imwald/ui/theme.py
  12. 20
      tests/test_forest_avatar.py
  13. 16
      tests/test_media_viewer.py
  14. 21
      tests/test_nip05_collect.py

79
src/imwald/core/author_html.py

@ -5,6 +5,7 @@ from __future__ import annotations
import html import html
from imwald.core.kind0_profile import display_name_from_profile, display_name_from_profile_or_hex 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: 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'<img src="{esc_fav}" width="14" height="14" alt="" '
f'style="vertical-align:middle;border-radius:3px;opacity:0.92" />'
)
if okv is True:
mark = f'<span style="color:{ok}" title="NIP-05 verified">✓</span>'
elif okv is False:
mark = f'<span style="color:{bad}" title="NIP-05 not verified">✗</span>'
else:
mark = f'<span style="color:{dim}" title="NIP-05 not verified yet">○</span>'
parts.append(
f'<span style="display:inline-flex;align-items:center;gap:5px;margin:4px 14px 0 0">'
f"{icon}"
f'<span style="color:{muted};font-size:14px">{esc}</span>{mark}'
f"</span>"
)
if not parts:
return ""
return f'<div style="display:flex;flex-wrap:wrap;align-items:center;margin-top:4px">{"".join(parts)}</div>'
def feed_op_author_compact_html(
parsed: dict[str, str | None], parsed: dict[str, str | None],
npub_bech: str, npub_bech: str,
pk_short: str,
nip_line_html: str,
about_line_html: str,
*,
pubkey_hex: str, pubkey_hex: str,
text: str, *,
status_inner_html: str,
nip05_chips_html: str,
muted: str, muted: str,
dim: str,
border: str, border: str,
) -> str: ) -> str:
"""Top-of-note author row: picture, display name, npub, optional nip05/about lines (links to profile tab).""" """Feed OP header: avatar, npub only, optional NIP-38 status (HTML), NIP-05 chips."""
disp = html.escape(display_name_from_profile(parsed))
pk_l = pubkey_hex.strip().lower() pk_l = pubkey_hex.strip().lower()
href = html.escape(f"imwald://pub/{pk_l}", quote=True) href = html.escape(f"imwald://pub/{pk_l}", quote=True)
av = avatar_img_or_placeholder(parsed, 52, border_hex=border, profile_href=href) av = avatar_img_or_placeholder(parsed, 52, border_hex=border, profile_href=href)
npub_e = html.escape(npub_bech) npub_e = html.escape(npub_bech)
pk_s = html.escape(pk_short) status_block = ""
inner = ( if status_inner_html.strip():
f'<div style="display:flex;align-items:flex-start;margin-bottom:12px">' status_block = f'<div style="margin-top:4px" class="md">{status_inner_html}</div>'
return (
f'<div style="display:flex;align-items:flex-start;margin-bottom:12px;gap:12px">'
f"{av}" f"{av}"
f'<a href="{href}" style="text-decoration:none;color:inherit;cursor:pointer;display:block;flex:1;min-width:0" ' f'<div style="flex:1;min-width:0">'
f'title="View profile">' f'<a href="{href}" style="text-decoration:none;color:inherit" title="View profile">'
f'<div style="font-size:21px;font-weight:600;color:{text}">{disp}</div>' f'<div style="color:{muted};font-size:15px;font-weight:500;word-break:break-all">{npub_e}</div>'
f'<div style="color:{muted};font-size:15px">{npub_e} · {pk_s}</div>'
f"{nip_line_html}{about_line_html}"
f"</a>" f"</a>"
f"</div>" f"{status_block}"
f"{nip05_chips_html}"
f"</div></div>"
) )
return inner
def thread_reply_author_row_html( def thread_reply_author_row_html(

23
src/imwald/core/forest_avatar.py

@ -6,11 +6,11 @@ import io
import random 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.""" """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] 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)) w = h = max(64, min(size, 256))
img = Image.new("RGB", (w, h), (22, 68, 42)) img = Image.new("RGB", (w, h), (22, 68, 42))
dr = ImageDraw.Draw(img) dr = ImageDraw.Draw(img)
@ -46,3 +46,22 @@ def build_forest_avatar_png(*, size: int = 192) -> bytes:
buf = io.BytesIO() buf = io.BytesIO()
paletted.save(buf, format="PNG", compress_level=9, optimize=True) paletted.save(buf, format="PNG", compress_level=9, optimize=True)
return buf.getvalue() 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()

55
src/imwald/core/kind0_profile.py

@ -5,6 +5,8 @@ from __future__ import annotations
import json import json
from typing import cast from typing import cast
from imwald.core.nip05 import scan_nip05_like_strings
def parse_kind0_profile(content: str) -> dict[str, str | None]: def parse_kind0_profile(content: str) -> dict[str, str | None]:
"""Return display fields from kind-0 ``content`` JSON (best-effort).""" """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" 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: 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.""" """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() n = (p.get("display_name") or p.get("name") or "").strip()

90
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://<domain>/.well-known/nostr.json?name=<local>`` (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

96
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)

2
src/imwald/core/nostr_engine.py

@ -33,7 +33,7 @@ from imwald.core.relay_policy import (
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Per-author backfill: profile + lists + NIP-30 inventory (Jumble-style). # 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" _AUTHOR_META_SUB_ID = "imwald-ameta"

177
src/imwald/ui/feed_page.py

@ -8,7 +8,7 @@ import re
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any, cast 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.QtGui import QDesktopServices, QKeyEvent, QTextOption
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@ -24,16 +24,26 @@ from PySide6.QtWidgets import (
QWidget, 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.database import Database
from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX 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.md_render import markdown_html_fragment, markdown_to_plain_text
from imwald.core.nip19 import encode_npub from imwald.core.nip19 import encode_npub
from imwald.core.nostr_engine import NostrEngine from imwald.core.nostr_engine import NostrEngine
from imwald.core.ranker import Ranker 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.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) 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 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: def _set_plain_height_to_content(te: QPlainTextEdit) -> None:
doc = te.document() doc = te.document()
lay = doc.documentLayout() lay = doc.documentLayout()
@ -158,6 +187,12 @@ class FeedPage(QWidget):
self._rendered_op_id: str | None = None self._rendered_op_id: str | None = None
self._rendered_reply_sig: tuple[str, ...] | None = None self._rendered_reply_sig: tuple[str, ...] | None = None
self._thread_card_by_eid: dict[str, QFrame] = {} 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 = QFrame()
self._engagement.setObjectName("EngagementBar") self._engagement.setObjectName("EngagementBar")
@ -264,6 +299,8 @@ class FeedPage(QWidget):
return return
s = url.toString() s = url.toString()
if s.startswith("https://") or s.startswith("http://"): if s.startswith("https://") or s.startswith("http://"):
if try_open_media_url_in_app(self.window(), url):
return
QDesktopServices.openUrl(url) QDesktopServices.openUrl(url)
def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802 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: def show_event(self, event_id: str) -> None:
ev = self._db.get_event(event_id) ev = self._db.get_event(event_id)
if not ev: if not ev:
self._op_ev_snapshot = None
self._op.setPlainText(f"(not in local DB yet) {event_id}") self._op.setPlainText(f"(not in local DB yet) {event_id}")
return return
self._queue = [cast(dict[str, Any], ev)] self._queue = [cast(dict[str, Any], ev)]
@ -369,6 +407,77 @@ class FeedPage(QWidget):
if not ev.get("deleted"): if not ev.get("deleted"):
self._db.mark_feed_viewed(self._feed_viewer_key(), ev["id"]) 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"<div style='color:{TEXT_DIM};font-size:15px;margin:6px 0'><i>Trending slice (nostrarchives)</i></div>"
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 (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body>"
f"{author_block}"
f"<div style='color:{TEXT_DIM};font-size:15px;margin-bottom:8px'>Kind {int(ev['kind'])} · {int(ev['created_at'])}</div>"
f"{tr}"
f"<div class=\"md\">{md_body}</div>"
f"<p style='color:{TEXT_DIM};font-size:14px;margin-top:14px'>{eid}</p>"
"</body></html>"
)
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: def _clear_thread_rows(self) -> None:
self._thread_card_by_eid.clear() self._thread_card_by_eid.clear()
while self._thread_layout.count(): while self._thread_layout.count():
@ -385,6 +494,7 @@ class FeedPage(QWidget):
if not self._queue: if not self._queue:
self._rendered_op_id = None self._rendered_op_id = None
self._rendered_reply_sig = 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._op.setPlainText("No events in local database yet — wait for relay sync.")
self._clear_thread_rows() self._clear_thread_rows()
self._why.setText("") self._why.setText("")
@ -394,6 +504,7 @@ class FeedPage(QWidget):
if ev.get("deleted"): if ev.get("deleted"):
self._rendered_op_id = None self._rendered_op_id = None
self._rendered_reply_sig = None self._rendered_reply_sig = None
self._op_ev_snapshot = None
raw = html.escape(ev.get("content") or "") raw = html.escape(ev.get("content") or "")
self._op.setHtml( self._op.setHtml(
f"<body style=\"color:{TEXT};background:transparent\">" f"<body style=\"color:{TEXT};background:transparent\">"
@ -431,54 +542,20 @@ class FeedPage(QWidget):
self._rendered_op_id = root_id self._rendered_op_id = root_id
self._rendered_reply_sig = reply_sig self._rendered_reply_sig = reply_sig
pk = op_pk self._op_ev_snapshot = ev
prof_row = self._db.get_latest_kind0_profile(pk) body = self._build_op_html(ev, None)
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"<div style='color:{TEXT_MUTED};font-size:15px;margin-top:4px'>{nip05}</div>" if nip05 else ""
)
about_line = f"<div style='color:{TEXT_DIM};font-size:15px;margin-top:6px'>{about}</div>" 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"<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,
nip30_tags=_nip30_tags(ev),
nip30_author_pubkey=pk,
)
body = (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body>"
f"{author_block}"
f"<div style='color:{TEXT_DIM};font-size:15px;margin-bottom:8px'>Kind {int(ev['kind'])} · {int(ev['created_at'])}</div>"
f"{tr}"
f"<div class=\"md\">{md_body}</div>"
f"<p style='color:{TEXT_DIM};font-size:14px;margin-top:14px'>{eid}</p>"
"</body></html>"
)
self._op.setHtml(body) 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) self._thread_scroll.setUpdatesEnabled(False)
try: try:
self._clear_thread_rows() self._clear_thread_rows()

47
src/imwald/ui/main_window.py

@ -301,8 +301,8 @@ class MainWindow(QMainWindow):
def _flush_ingest_ui_refresh(self) -> None: def _flush_ingest_ui_refresh(self) -> None:
if self._stack.currentIndex() == 0: if self._stack.currentIndex() == 0:
cur = self._browser_tabs.currentWidget() cur = self._browser_tabs.currentWidget()
if cur is self._feed: if isinstance(cur, FeedPage):
self._feed.refresh_tail() cur.refresh_tail()
elif isinstance(cur, ProfilePage): elif isinstance(cur, ProfilePage):
cur.refresh() cur.refresh()
self._notif.refresh_all() self._notif.refresh_all()
@ -432,7 +432,7 @@ class MainWindow(QMainWindow):
existing.refresh() existing.refresh()
return return
page = ProfilePage(self._db, self._engine, pk, self._browser_tabs) 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) page.open_profile.connect(self._open_profile_tab)
self._profile_tabs_by_pubkey[pk] = page self._profile_tabs_by_pubkey[pk] = page
self._browser_tabs.addTab(page, page.tab_title()) self._browser_tabs.addTab(page, page.tab_title())
@ -448,6 +448,7 @@ class MainWindow(QMainWindow):
if v is w: if v is w:
del self._profile_tabs_by_pubkey[k] del self._profile_tabs_by_pubkey[k]
break break
if w is not None:
w.deleteLater() w.deleteLater()
def _wire_pages(self) -> None: def _wire_pages(self) -> None:
@ -465,6 +466,46 @@ class MainWindow(QMainWindow):
self._go_stack_page(0) self._go_stack_page(0)
self._feed.show_event(event_id) 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: 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) acc = next((a for a in self._accounts if a.pubkey.lower() == pubkey.lower()), None)
if not acc: if not acc:

376
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"<svg", b"<?xml")):
svg = QSvgWidget()
svg.load(QByteArray(data))
svg.setMinimumHeight(320)
self._stack_layout.addWidget(svg, stretch=1)
return
img = QImage.fromData(data)
if img.isNull():
self._stack_layout.addWidget(QLabel("Could not decode this image."), stretch=1)
return
pm = QPixmap.fromImage(img)
canvas = _ImageCanvas()
canvas.set_pixmap(pm)
self._stack_layout.addWidget(canvas, stretch=1)
def _on_image_fail(self, msg: str) -> None:
w = self._image_wait
if w is None:
return
w.setText(f"Could not load image.\n{msg}")
w.setStyleSheet(f"color:{TEXT_DIM};font-size:14px;padding:24px")
def _setup_av(self, url: QUrl, kind: Literal["audio", "video"]) -> None:
self._audio_out = QAudioOutput(self)
self._player = QMediaPlayer(self)
self._player.setAudioOutput(self._audio_out)
vw: QVideoWidget | None = None
if kind == "video":
vw = QVideoWidget()
vw.setMinimumSize(480, 270)
self._player.setVideoOutput(vw)
self._stack_layout.addWidget(vw, stretch=1)
else:
art = QLabel("")
art.setAlignment(Qt.AlignmentFlag.AlignCenter)
art.setStyleSheet(f"font-size:96px;color:{TEXT_DIM};padding:48px")
self._stack_layout.addWidget(art, stretch=1)
ctrl = QHBoxLayout()
self._play_btn = QPushButton()
self._play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
self._play_btn.clicked.connect(self._toggle_play)
ctrl.addWidget(self._play_btn)
self._pos_slider = QSlider(Qt.Orientation.Horizontal)
self._pos_slider.setRange(0, 0)
self._pos_slider.sliderMoved.connect(self._seek)
ctrl.addWidget(self._pos_slider, stretch=1)
self._time_lbl = QLabel("0:00 / 0:00")
self._time_lbl.setStyleSheet(f"color:{TEXT_MUTED}")
ctrl.addWidget(self._time_lbl)
wrap = QWidget()
wrap.setLayout(ctrl)
self._stack_layout.addWidget(wrap)
self._player.setSource(url)
self._player.playbackStateChanged.connect(self._sync_play_icon)
self._player.positionChanged.connect(self._on_position)
self._player.durationChanged.connect(self._on_duration)
self._player.errorOccurred.connect(self._on_player_error) # type: ignore[arg-type]
self._player.play()
def _toggle_play(self) -> None:
if self._player is None:
return
if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
self._player.pause()
else:
self._player.play()
def _sync_play_icon(self) -> None:
if self._player is None:
return
if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
self._play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPause))
else:
self._play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
def _seek(self, pos: int) -> None:
if self._player is not None:
self._player.setPosition(pos)
def _on_position(self, pos: int) -> None:
if self._player is None:
return
d = self._player.duration()
if d > 0 and self._pos_slider.maximum() != d:
self._pos_slider.setMaximum(d)
self._pos_slider.blockSignals(True)
self._pos_slider.setValue(pos)
self._pos_slider.blockSignals(False)
self._time_lbl.setText(f"{self._fmt_ms(pos)} / {self._fmt_ms(max(d, 0))}")
def _on_duration(self, d: int) -> None:
self._pos_slider.setMaximum(max(d, 0))
@staticmethod
def _fmt_ms(ms: int) -> str:
s = max(ms, 0) // 1000
m, s = divmod(s, 60)
if m >= 60:
h, m = divmod(m, 60)
return f"{h}:{m:02d}:{s:02d}"
return f"{m}:{s:02d}"
def _on_player_error(self, *_args: object) -> None:
if self._player is None:
return
err = self._player.errorString()
QMessageBox.warning(self, "Playback", err or "Media playback failed.")
def closeEvent(self, event: QCloseEvent) -> None:
if self._player is not None:
self._player.stop()
super().closeEvent(event)
def try_open_media_url_in_app(parent: QWidget | None, url: QUrl) -> bool:
"""
If ``url`` looks like a direct media file we support, open :class:`MediaViewerDialog` and return ``True``.
Otherwise return ``False`` so callers can fall back to :class:`QDesktopServices`.
"""
kind = classify_media_url(url)
if kind is None:
return False
dlg = MediaViewerDialog(parent, url, kind)
dlg.show()
dlg.raise_()
dlg.activateWindow()
return True

526
src/imwald/ui/profile_page.py

@ -4,27 +4,279 @@ from __future__ import annotations
import html import html
import json import json
from datetime import datetime, timezone
from typing import cast from typing import cast
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, Signal, QUrl from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal, QUrl
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices, QMouseEvent, QPainter, QPainterPath, QPixmap, QResizeEvent
from PySide6.QtWidgets import QFrame, QScrollArea, QTabWidget, QVBoxLayout, QWidget from PySide6.QtWidgets import QFrame, QLabel, QSizePolicy, QSplitter, QTabWidget, QVBoxLayout, QWidget
from imwald.core.author_html import avatar_img_or_placeholder
from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX
from imwald.core.database import Database from imwald.core.database import Database
from imwald.core.forest_avatar import build_forest_avatar_png, build_forest_banner_png
from imwald.core.kind0_profile import display_name_from_profile_or_hex, parse_kind0_profile from imwald.core.kind0_profile import display_name_from_profile_or_hex, parse_kind0_profile
from imwald.core.profile_lnurl import build_merged_lnurl_pay_section, collect_unique_lnurlp_urls from imwald.core.profile_lnurl import build_merged_lnurl_pay_section, collect_unique_lnurlp_urls
from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary
from imwald.core.nip19 import encode_npub from imwald.core.nip19 import encode_npub
from imwald.core.nostr_engine import NostrEngine from imwald.core.nostr_engine import NostrEngine
from imwald.core.relay_list import parse_kind10002_tags from imwald.core.relay_list import parse_kind10002_tags
from imwald.ui.media_viewer_dialog import try_open_media_url_in_app
from imwald.ui.note_text_browser import NoteTextBrowser 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_SOFT, BG_CARD, BG_CODE, BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED
# Notes to list under “Recent in local DB” (feed-shaped kinds). # Notes to list under “Recent in local DB” (feed-shaped kinds).
_PROFILE_NOTE_KINDS: tuple[int, ...] = (1, 6, 20, 21, 30023, 9802, 11) _PROFILE_NOTE_KINDS: tuple[int, ...] = (1, 6, 20, 21, 30023, 9802, 11)
PROFILE_HERO_BANNER_H = 240
PROFILE_HERO_AVATAR = 136
PROFILE_HERO_OVERLAP = 0.48 # fraction of avatar height drawn above the banner bottom edge
def _cover_pixmap(src: QPixmap, tw: int, th: int) -> QPixmap:
if src.isNull() or tw < 2 or th < 2:
return QPixmap()
sc = src.scaled(
tw,
th,
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation,
)
sw, sh = sc.width(), sc.height()
x = max(0, (sw - tw) // 2)
y = max(0, (sh - th) // 2)
return sc.copy(x, y, tw, th)
def _square_center_crop(pm: QPixmap) -> QPixmap:
if pm.isNull():
return QPixmap()
s = min(pm.width(), pm.height())
if s < 2:
return pm
x = (pm.width() - s) // 2
y = (pm.height() - s) // 2
return pm.copy(x, y, s, s)
def _circle_pixmap(src: QPixmap, diameter: int) -> QPixmap:
if src.isNull() or diameter < 4:
return QPixmap()
scaled = src.scaled(
diameter,
diameter,
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation,
)
sw, sh = scaled.width(), scaled.height()
x = max(0, (sw - diameter) // 2)
y = max(0, (sh - diameter) // 2)
inner = scaled.copy(x, y, diameter, diameter)
out = QPixmap(diameter, diameter)
out.fill(Qt.GlobalColor.transparent)
painter = QPainter(out)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
path = QPainterPath()
path.addEllipse(0, 0, diameter, diameter)
painter.setClipPath(path)
painter.drawPixmap(0, 0, inner)
painter.end()
return out
class _BannerLabel(QLabel):
"""Opens the remote banner URL in the system browser when set."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._open_url: str | None = None
self.setCursor(Qt.CursorShape.PointingHandCursor)
def set_open_url(self, url: str | None) -> None:
self._open_url = url.strip() if url else None
self.setCursor(
Qt.CursorShape.PointingHandCursor if self._open_url else Qt.CursorShape.ArrowCursor
)
def mousePressEvent(self, event: QMouseEvent) -> None:
if self._open_url and event.button() == Qt.MouseButton.LeftButton:
q = QUrl(self._open_url)
if not try_open_media_url_in_app(self.window(), q):
QDesktopServices.openUrl(q)
super().mousePressEvent(event)
class ProfileHeroFrame(QFrame):
"""Full-width cover + circular avatar overlapping the lower banner (social-style header)."""
BANNER_H = PROFILE_HERO_BANNER_H
AVATAR = PROFILE_HERO_AVATAR
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("ProfileHeroFrame")
self._banner_src = QPixmap()
self._disp = QLabel(self)
self._npub = QLabel(self)
self._pk = QLabel(self)
self._nip05 = QLabel(self)
self._meta = QLabel(self)
self._banner_lbl = _BannerLabel(self)
self._banner_lbl.setScaledContents(False)
self._avatar_lbl = QLabel(self)
self._avatar_lbl.setFixedSize(self.AVATAR, self.AVATAR)
self._avatar_lbl.setScaledContents(False)
self._avatar_lbl.setStyleSheet(
f"QLabel {{ border: 3px solid {BORDER}; border-radius: {self.AVATAR // 2}px; "
f"background-color: {BG_CODE}; }}"
)
for lb in (self._disp, self._npub, self._pk, self._nip05, self._meta):
lb.setWordWrap(True)
lb.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self._disp.setStyleSheet(f"color:{TEXT}; font-size:26px; font-weight:700;")
self._npub.setStyleSheet(f"color:{TEXT_MUTED}; font-size:14px;")
self._pk.setStyleSheet(f"color:{TEXT_DIM}; font-size:12px;")
self._nip05.setStyleSheet(f"color:{TEXT_MUTED}; font-size:15px;")
self._meta.setStyleSheet(f"color:{TEXT_DIM}; font-size:12px;")
self._relayout()
def set_banner_source(self, pm: QPixmap) -> None:
self._banner_src = pm if not pm.isNull() else QPixmap()
self._relayout()
def set_banner_open_url(self, url: str | None) -> None:
self._banner_lbl.set_open_url(url)
def set_avatar_pixmap(self, pm: QPixmap) -> None:
if pm.isNull():
self._avatar_lbl.clear()
else:
self._avatar_lbl.setPixmap(pm)
def set_identity(
self,
display: str,
npub: str,
pubkey_hex: str,
nip05: str | None,
k0_meta: str | None,
) -> None:
self._disp.setText(display)
self._npub.setText(npub)
self._pk.setText(pubkey_hex)
self._nip05.setText(nip05 or "")
self._nip05.setVisible(bool(nip05 and nip05.strip()))
self._meta.setText(k0_meta or "")
self._meta.setVisible(bool(k0_meta and k0_meta.strip()))
self._relayout()
def minimumSizeHint(self) -> QSize: # noqa: N802
overlap = int(self.AVATAR * PROFILE_HERO_OVERLAP)
h = self.BANNER_H + self.AVATAR - overlap + 18
return QSize(280, h)
def resizeEvent(self, event: QResizeEvent) -> None:
self._relayout()
super().resizeEvent(event)
def _relayout(self) -> None:
w = max(self.width(), 80)
bh = self.BANNER_H
av = self.AVATAR
cover = _cover_pixmap(self._banner_src, w, bh)
self._banner_lbl.setPixmap(cover)
self._banner_lbl.setGeometry(0, 0, w, bh)
ax = 22
ay = bh - int(av * PROFILE_HERO_OVERLAP)
self._avatar_lbl.setGeometry(ax, ay, av, av)
tx = ax + av + 16
tw = w - tx - 16
y0 = ay + 4
self._disp.setGeometry(tx, y0, tw, 34)
self._npub.setGeometry(tx, y0 + 36, tw, 44)
self._pk.setGeometry(tx, y0 + 82, tw, 36)
y1 = y0 + 118
if self._nip05.isVisible():
self._nip05.setGeometry(tx, y1, tw, 28)
y1 += 30
if self._meta.isVisible():
self._meta.setGeometry(tx, y1, tw, 40)
overlap = int(av * PROFILE_HERO_OVERLAP)
total_h = bh + av - overlap + 14
self.setFixedHeight(max(total_h, self.minimumSizeHint().height()))
self._avatar_lbl.raise_()
class _CoverImageSignals(QObject):
banner_ready = Signal(object, int)
avatar_ready = Signal(object, int)
class _ProfileHttpImageRunnable(QRunnable):
def __init__(self, url: str, gen: int, mode: str, out: _CoverImageSignals) -> None:
super().__init__()
self._url = url
self._gen = gen
self._mode = mode
self._out = out
def run(self) -> None:
empty = QPixmap()
pm = QPixmap()
try:
from urllib.request import Request, urlopen
req = Request(self._url, headers={"User-Agent": "imwald/1"}, method="GET")
with urlopen(req, timeout=16) as resp: # noqa: S310
data = resp.read(6 * 1024 * 1024 + 1)
if len(data) > 6 * 1024 * 1024 or not pm.loadFromData(data):
self._emit_fail()
return
except OSError:
self._emit_fail()
return
if self._mode == "banner":
self._out.banner_ready.emit(pm, self._gen)
elif self._mode == "avatar":
sq = _square_center_crop(pm)
if sq.isNull():
self._out.avatar_ready.emit(empty, self._gen)
return
scaled = sq.scaled(
400,
400,
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
circ = _circle_pixmap(scaled, ProfileHeroFrame.AVATAR)
self._out.avatar_ready.emit(circ, self._gen)
else:
self._emit_fail()
def _emit_fail(self) -> None:
if self._mode == "banner":
self._out.banner_ready.emit(QPixmap(), self._gen)
else:
self._out.avatar_ready.emit(QPixmap(), self._gen)
def _sec_title(label: str) -> str:
return (
f'<div style="font-size:11px;font-weight:700;color:{TEXT_MUTED};text-transform:uppercase;'
f'letter-spacing:0.07em;margin:0 0 12px 0">{html.escape(label)}</div>'
)
def _fmt_event_time(created_at: int | float | str | None) -> str:
if created_at is None:
return ""
try:
ts = int(created_at)
except (TypeError, ValueError):
return str(created_at)
try:
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d · %H:%M UTC")
except (OSError, OverflowError, ValueError):
return str(ts)
class _ProfileLnurlSignals(QObject): class _ProfileLnurlSignals(QObject):
finished = Signal(str, int) finished = Signal(str, int)
@ -45,7 +297,7 @@ class _ProfileLnurlRunnable(QRunnable):
class ProfilePage(QWidget): class ProfilePage(QWidget):
"""One pubkey: metadata, NIP-65 relays, follows (kind 3), emoji inventory, raw JSON, recent notes.""" """One pubkey: metadata, NIP-65 relays, follows (kind 3), emoji inventory, raw JSON, recent notes."""
open_note = Signal(str) open_note_new_tab = Signal(str)
open_profile = Signal(str) open_profile = Signal(str)
def __init__( def __init__(
@ -60,25 +312,50 @@ class ProfilePage(QWidget):
self._db = db self._db = db
self._engine = engine self._engine = engine
self._pubkey = pubkey_hex.strip().lower() self._pubkey = pubkey_hex.strip().lower()
scroll = QScrollArea(self) self._hero = ProfileHeroFrame(self)
scroll.setWidgetResizable(True) self._left_body = NoteTextBrowser()
scroll.setFrameShape(QFrame.Shape.NoFrame) self._left_body.setObjectName("ProfileBodyLeft")
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self._left_body.setOpenLinks(False)
self._body = NoteTextBrowser() self._left_body.setOpenExternalLinks(False)
self._body.setObjectName("ProfileBody") self._left_body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._body.setOpenLinks(False) self._left_body.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._body.setOpenExternalLinks(False) self._left_body.anchorClicked.connect(self._dispatch_profile_anchor)
self._body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self._feed_body = NoteTextBrowser()
self._body.anchorClicked.connect(self._on_anchor) self._feed_body.setObjectName("ProfileBodyFeed")
scroll.setWidget(self._body) self._feed_body.setOpenLinks(False)
self._feed_body.setOpenExternalLinks(False)
self._feed_body.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._feed_body.anchorClicked.connect(self._dispatch_profile_anchor)
self._left_column = QWidget(self)
left_lay = QVBoxLayout(self._left_column)
left_lay.setContentsMargins(0, 0, 0, 0)
left_lay.setSpacing(0)
left_lay.addWidget(self._hero, 0)
left_lay.addWidget(self._left_body, 1)
split = QSplitter(Qt.Orientation.Horizontal, self)
split.setObjectName("ProfileSplit")
split.setChildrenCollapsible(False)
split.addWidget(self._left_column)
split.addWidget(self._feed_body)
split.setStretchFactor(0, 100)
split.setStretchFactor(1, 58)
split.setSizes([720, 400])
self._feed_body.setMinimumWidth(260)
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0) lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(scroll) lay.addWidget(split)
self._lnurl_gen = 0 self._lnurl_gen = 0
self._lnurl_sigs = _ProfileLnurlSignals(self) self._lnurl_sigs = _ProfileLnurlSignals(self)
self._lnurl_sigs.finished.connect(self._on_lnurl_profile_ready) self._lnurl_sigs.finished.connect(self._on_lnurl_profile_ready)
self._lnurl_pool = QThreadPool(self) self._lnurl_pool = QThreadPool(self)
self._lnurl_pool.setMaxThreadCount(1) self._lnurl_pool.setMaxThreadCount(1)
self._cover_banner_gen = 0
self._cover_avatar_gen = 0
self._cov_sigs = _CoverImageSignals(self)
self._cov_sigs.banner_ready.connect(self._on_cover_banner_http)
self._cov_sigs.avatar_ready.connect(self._on_cover_avatar_http)
self._cov_pool = QThreadPool(self)
self._cov_pool.setMaxThreadCount(3)
self.refresh() self.refresh()
def tab_title(self) -> str: def tab_title(self) -> str:
@ -92,6 +369,18 @@ class ProfilePage(QWidget):
return return
self.refresh(from_lnurl=True, lnurl_html=html) self.refresh(from_lnurl=True, lnurl_html=html)
def _on_cover_banner_http(self, pm: object, gen: int) -> None:
if gen != self._cover_banner_gen:
return
if isinstance(pm, QPixmap) and not pm.isNull():
self._hero.set_banner_source(pm)
def _on_cover_avatar_http(self, pm: object, gen: int) -> None:
if gen != self._cover_avatar_gen:
return
if isinstance(pm, QPixmap) and not pm.isNull():
self._hero.set_avatar_pixmap(pm)
def refresh(self, *, from_lnurl: bool = False, lnurl_html: str | None = None) -> None: def refresh(self, *, from_lnurl: bool = False, lnurl_html: str | None = None) -> None:
if not from_lnurl: if not from_lnurl:
self._engine.enqueue_author_metadata(self._pubkey) self._engine.enqueue_author_metadata(self._pubkey)
@ -129,35 +418,57 @@ class ProfilePage(QWidget):
gen = self._lnurl_gen gen = self._lnurl_gen
self._lnurl_pool.start(_ProfileLnurlRunnable(lnurls, gen, self._lnurl_sigs)) self._lnurl_pool.start(_ProfileLnurlRunnable(lnurls, gen, self._lnurl_sigs))
live_lnurl = f"<p style='color:{TEXT_DIM}'><i>Fetching LNURL-pay metadata…</i></p>" live_lnurl = f"<p style='color:{TEXT_DIM}'><i>Fetching LNURL-pay metadata…</i></p>"
pay_block = "" pay_card = ""
if pay_static or live_lnurl: if pay_static or live_lnurl:
pay_block = ( pay_card = f"{_sec_title('Lightning · NIP-57')}<div>{pay_static}{live_lnurl}</div>"
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Lightning (NIP-57)</h3>"
f"<div style='margin-bottom:8px'>{pay_static}{live_lnurl}</div>"
)
disp = html.escape(display_name_from_profile_or_hex(parsed, pk)) disp_plain = display_name_from_profile_or_hex(parsed, pk)
prof_href = html.escape(f"imwald://pub/{pk}", quote=True) nip05_plain = (parsed.get("nip05") or "").strip() if parsed.get("nip05") else ""
av = avatar_img_or_placeholder(parsed, 72, border_hex=BORDER, profile_href=prof_href) banner_raw = parsed.get("banner")
nip05 = html.escape((parsed.get("nip05") or "").strip()) if parsed.get("nip05") else "" banner_https = (
nip05_html = ( str(banner_raw).strip()
f"<div style='color:{TEXT_MUTED};font-size:16px;margin-top:6px'>{nip05}</div>" if nip05 else "" if banner_raw and str(banner_raw).strip().startswith("https://")
else ""
)
picture_raw = parsed.get("picture")
picture_https = (
str(picture_raw).strip()
if picture_raw and str(picture_raw).strip().startswith("https://")
else ""
) )
banner = parsed.get("banner") forest_seed = int(pk[:16], 16) % (2**31) if len(pk) >= 16 else 0
banner_html = "" ph_b = QPixmap()
if banner and str(banner).strip().startswith("https://"): bh_bytes = build_forest_banner_png(
bu = html.escape(str(banner).strip(), quote=True) width=2000, height=ProfileHeroFrame.BANNER_H + 48, seed=forest_seed
bw = IMAGE_DISPLAY_MAX_WIDTH_PX )
banner_html = ( if not ph_b.loadFromData(bh_bytes):
f"<div style='margin-bottom:12px;border-radius:10px;overflow:hidden;" ph_b.loadFromData(
f"max-width:{bw}px'>" build_forest_banner_png(width=2000, height=ProfileHeroFrame.BANNER_H + 48, seed=42)
f'<a class="imwald-fullimg" href="{bu}" title="View full size" style="text-decoration:none">' )
f'<img src="{bu}" alt="" style="max-width:100%;width:100%;max-height:160px;object-fit:cover" />' self._hero.set_banner_source(ph_b if not ph_b.isNull() else QPixmap())
f"</a></div>" ph_a = QPixmap()
if not ph_a.loadFromData(build_forest_avatar_png(size=384, seed=forest_seed)):
ph_a.loadFromData(build_forest_avatar_png(size=384, seed=42))
av_circ = _circle_pixmap(ph_a, ProfileHeroFrame.AVATAR)
self._hero.set_avatar_pixmap(av_circ)
self._cover_banner_gen += 1
b_gen = self._cover_banner_gen
self._cover_avatar_gen += 1
a_gen = self._cover_avatar_gen
if banner_https:
self._cov_pool.start(_ProfileHttpImageRunnable(banner_https, b_gen, "banner", self._cov_sigs))
if picture_https:
self._cov_pool.start(_ProfileHttpImageRunnable(picture_https, a_gen, "avatar", self._cov_sigs))
self._hero.set_banner_open_url(banner_https or None)
k0_meta_plain = ""
if k0_ev:
k0_meta_plain = (
f"Kind 0 id {str(k0_ev['id'])[:20]}… · profile updated {_fmt_event_time(created0)}"
) )
self._hero.set_identity(disp_plain, npub, pk, nip05_plain or None, k0_meta_plain or None)
about_raw = (parsed.get("about") or "").strip() about_raw = (parsed.get("about") or "").strip()
about_md = "" about_inner = ""
if about_raw: if about_raw:
frag = markdown_html_fragment( frag = markdown_html_fragment(
about_raw, about_raw,
@ -165,7 +476,7 @@ class ProfilePage(QWidget):
nip30_tags=tags0 or None, nip30_tags=tags0 or None,
nip30_author_pubkey=pk, nip30_author_pubkey=pk,
) )
about_md = f"<h3 style='color:{TEXT};margin:16px 0 8px'>About</h3><div class=\"md\">{frag}</div>" about_inner = f"{_sec_title('About')}<div class=\"md\">{frag}</div>"
raw_json = "" raw_json = ""
try: try:
@ -175,27 +486,27 @@ class ProfilePage(QWidget):
except json.JSONDecodeError: except json.JSONDecodeError:
raw_json = content or "" raw_json = content or ""
raw_esc = html.escape(raw_json[:12000] + ("" if len(raw_json) > 12000 else ""), quote=False) raw_esc = html.escape(raw_json[:12000] + ("" if len(raw_json) > 12000 else ""), quote=False)
json_block = ( json_inner = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Kind 0 JSON (full)</h3>" f"{_sec_title('Kind 0 JSON')}"
f"<pre style='color:{TEXT_DIM};font-size:14px;white-space:pre-wrap;word-break:break-all;" f"<pre style='color:{TEXT_DIM};font-size:13px;white-space:pre-wrap;word-break:break-all;"
f"background:rgba(0,0,0,0.25);padding:12px;border-radius:8px;border:1px solid {BORDER}'>{raw_esc}</pre>" f"background:{BG_CODE};padding:12px;border-radius:8px;border:1px solid {BORDER};margin:0'>{raw_esc}</pre>"
) )
k10002 = self._db.get_latest_kind10002_event(pk) k10002 = self._db.get_latest_kind10002_event(pk)
relay_html = ( relay_inner = (
f"<p style='color:{TEXT_DIM};font-size:15px'>" f"<p style='color:{TEXT_DIM};font-size:15px;margin:0'>"
f"<i>No NIP-65 relay list (kind 10002) in local DB yet.</i></p>" f"<i>No NIP-65 relay list (kind 10002) in local DB yet.</i></p>"
) )
if k10002: if k10002:
reads, writes = parse_kind10002_tags(k10002.get("tags") or []) reads, writes = parse_kind10002_tags(k10002.get("tags") or [])
r_esc = "<br>".join(html.escape(u) for u in reads[:40]) r_esc = "<br>".join(html.escape(u) for u in reads[:40])
w_esc = "<br>".join(html.escape(u) for u in writes[:40]) w_esc = "<br>".join(html.escape(u) for u in writes[:40])
relay_html = ( relay_inner = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Relays (NIP-65, kind 10002)</h3>" f"{_sec_title('Relays · NIP-65 (kind 10002)')}"
f"<p style='color:{TEXT_MUTED};font-size:15px'><b>Read</b></p>" f"<p style='color:{TEXT_MUTED};font-size:14px;margin:0 0 6px 0'><b>Read</b></p>"
f"<div style='color:{TEXT_DIM};font-size:14px'>{r_esc or ''}</div>" f"<div style='color:{TEXT_DIM};font-size:14px;line-height:1.45'>{r_esc or ''}</div>"
f"<p style='color:{TEXT_MUTED};font-size:15px;margin-top:10px'><b>Write</b></p>" f"<p style='color:{TEXT_MUTED};font-size:14px;margin:12px 0 6px 0'><b>Write</b></p>"
f"<div style='color:{TEXT_DIM};font-size:14px'>{w_esc or ''}</div>" f"<div style='color:{TEXT_DIM};font-size:14px;line-height:1.45'>{w_esc or ''}</div>"
) )
follows = self._db.get_latest_kind3_contact_pubkeys(pk, limit=400) follows = self._db.get_latest_kind3_contact_pubkeys(pk, limit=400)
@ -209,9 +520,9 @@ class ProfilePage(QWidget):
f'<span style="color:{TEXT_DIM};font-size:13px"> · {html.escape(fp[:16])}…</span></div>' f'<span style="color:{TEXT_DIM};font-size:13px"> · {html.escape(fp[:16])}…</span></div>'
) )
_no_follow = f"<i style='color:{TEXT_DIM}'>No kind 3 in local DB.</i>" _no_follow = f"<i style='color:{TEXT_DIM}'>No kind 3 in local DB.</i>"
follow_block = ( follow_inner = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Following (kind 3, local snapshot)</h3>" f"{_sec_title('Following · kind 3 (local)')}"
f"<div style='font-size:14px'>{''.join(follow_lines) or _no_follow}</div>" f"<div style='font-size:14px;line-height:1.45'>{''.join(follow_lines) or _no_follow}</div>"
) )
nip30 = self._db.get_author_nip30_emoji_urls(pk) nip30 = self._db.get_author_nip30_emoji_urls(pk)
@ -222,10 +533,7 @@ class ProfilePage(QWidget):
f'<a href="{html.escape(url, quote=True)}" style="color:{TEXT}">{html.escape(url[:48])}…</a></div>' f'<a href="{html.escape(url, quote=True)}" style="color:{TEXT}">{html.escape(url[:48])}…</a></div>'
) )
_no_emoji = f"<i style='color:{TEXT_DIM}'>No emoji packs indexed yet.</i>" _no_emoji = f"<i style='color:{TEXT_DIM}'>No emoji packs indexed yet.</i>"
emoji_block = ( emoji_inner = f"{_sec_title('Custom emoji · NIP-30')}" f"<div>{''.join(em_lines) or _no_emoji}</div>"
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Custom emoji (NIP-30, local)</h3>"
f"<div>{''.join(em_lines) or _no_emoji}</div>"
)
notes = self._db.list_events_by_pubkey(pk, kinds=_PROFILE_NOTE_KINDS, limit=40) notes = self._db.list_events_by_pubkey(pk, kinds=_PROFILE_NOTE_KINDS, limit=40)
note_lines: list[str] = [] note_lines: list[str] = []
@ -235,62 +543,73 @@ class ProfilePage(QWidget):
nip = cast(list[list[str]], ev["tags"]) if isinstance(ev.get("tags"), list) else None nip = cast(list[list[str]], ev["tags"]) if isinstance(ev.get("tags"), list) else None
snip = markdown_plain_summary( snip = markdown_plain_summary(
ev.get("content") or "", ev.get("content") or "",
max_len=72, max_len=96,
db=self._db, db=self._db,
nip30_tags=nip, nip30_tags=nip,
nip30_author_pubkey=str(ev.get("pubkey") or "") or None, nip30_author_pubkey=str(ev.get("pubkey") or "") or None,
) )
t_human = _fmt_event_time(ev.get("created_at"))
kind_lbl = int(ev["kind"])
esc_href = html.escape(href, quote=True)
note_lines.append( note_lines.append(
f'<div style="margin:8px 0;padding:8px;border:1px solid {BORDER};border-radius:8px">' f'<a href="{esc_href}" style="display:block;text-decoration:none;color:{TEXT};'
f"<span style='color:{TEXT_MUTED};font-size:13px'>k{int(ev['kind'])} · {int(ev['created_at'])}</span><br>" f"background:{BG_CARD};border:1px solid {BORDER};border-left:3px solid {ACCENT_SOFT};"
f'<a href="{html.escape(href, quote=True)}" style="color:{TEXT};font-weight:600;text-decoration:none">' f'border-radius:11px;padding:12px 14px 12px 13px;margin-bottom:11px">'
f"Open in feed</a>" f"<div style='color:{TEXT_MUTED};font-size:12px;font-weight:600'>"
f"<div style='color:{TEXT_DIM};margin-top:6px;font-size:15px'>{html.escape(snip)}</div>" f"Kind {kind_lbl} · {html.escape(t_human)}</div>"
"</div>" f"<div style='color:{TEXT_DIM};margin-top:8px;font-size:15px;line-height:1.45'>"
f"{html.escape(snip)}</div>"
f"<div style='color:{ACCENT_SOFT};font-size:12px;margin-top:8px;font-weight:600'>"
f"Open in new tab →</div></a>"
) )
_no_notes = f"<i style='color:{TEXT_DIM}'>No matching notes stored yet.</i>" _no_notes = "No matching notes stored yet."
notes_block = (
f"<h3 style='color:{TEXT};margin:16px 0 8px'>Recent notes (local DB)</h3>" card_style = (
f"{''.join(note_lines) or _no_notes}" f"background:{BG_CARD};border:1px solid {BORDER};border-radius:14px;"
f"padding:16px 18px;margin-bottom:14px"
) )
left_parts: list[str] = []
if about_inner:
left_parts.append(f'<div style="{card_style}">{about_inner}</div>')
if pay_card:
left_parts.append(f'<div style="{card_style}">{pay_card}</div>')
left_parts.append(f'<div style="{card_style}">{relay_inner}</div>')
left_parts.append(f'<div style="{card_style}">{follow_inner}</div>')
left_parts.append(f'<div style="{card_style}">{emoji_inner}</div>')
left_parts.append(f'<div style="{card_style}">{json_inner}</div>')
k0_meta = "" left_doc = (
if k0_ev: "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
k0_meta = ( f"{FEED_DOC_CSS}</head><body style='padding:14px 16px 28px 18px'>"
f"<p style='color:{TEXT_DIM};font-size:14px'>Kind 0 event id: <code>{html.escape(str(k0_ev['id']))}</code>" f"{''.join(left_parts)}</body></html>"
f" · updated {created0}</p>" )
)
doc = ( n_notes = len(notes)
feed_intro = (
f'<div style="margin-bottom:16px;padding-bottom:14px;border-bottom:1px solid {BORDER}">'
f'<div style="font-size:20px;font-weight:700;color:{TEXT};letter-spacing:-0.02em">Posts</div>'
f'<div style="color:{TEXT_DIM};font-size:14px;margin-top:6px;line-height:1.4">'
f"{n_notes} recent note{'' if n_notes == 1 else 's'} indexed on this device. "
f"Click a card to open the thread in a <b style='color:{TEXT}'>new tab</b>.</div></div>"
)
if note_lines:
feed_inner = "".join(note_lines)
else:
feed_inner = f"<p style='color:{TEXT_DIM};font-size:15px'><i>{html.escape(_no_notes)}</i></p>"
feed_doc = (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">" "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"{FEED_DOC_CSS}</head><body style='padding:12px 14px'>" f"{FEED_DOC_CSS}</head><body style='padding:14px 14px 28px 12px'>"
f"{banner_html}" f"{feed_intro}{feed_inner}</body></html>"
f"<div style='display:flex;align-items:flex-start;gap:14px;margin-bottom:8px'>"
f"{av}"
f"<div style='flex:1;min-width:0'>"
f"<div style='font-size:26px;font-weight:700;color:{TEXT}'>{disp}</div>"
f"<div style='color:{TEXT_MUTED};font-size:15px;margin-top:4px'>{html.escape(npub)}</div>"
f"<div style='color:{TEXT_DIM};font-size:14px;margin-top:2px'>{html.escape(pk[:24])}…</div>"
f"{nip05_html}"
f"</div></div>"
f"{k0_meta}"
f"{about_md}"
f"{pay_block}"
f"{relay_html}"
f"{follow_block}"
f"{emoji_block}"
f"{json_block}"
f"{notes_block}"
"</body></html>"
) )
self._body.setHtml(doc) self._left_body.setHtml(left_doc)
self._feed_body.setHtml(feed_doc)
tw = self.parentWidget() tw = self.parentWidget()
if isinstance(tw, QTabWidget): if isinstance(tw, QTabWidget):
i = tw.indexOf(self) i = tw.indexOf(self)
if i >= 0: if i >= 0:
tw.setTabText(i, self.tab_title()) tw.setTabText(i, self.tab_title())
def _on_anchor(self, url: QUrl) -> None: def _dispatch_profile_anchor(self, url: QUrl) -> None:
s = url.toString() s = url.toString()
if url.scheme() == "imwald" and url.host() == "pub": if url.scheme() == "imwald" and url.host() == "pub":
tail = (url.path() or "").strip("/").lower() tail = (url.path() or "").strip("/").lower()
@ -300,7 +619,8 @@ class ProfilePage(QWidget):
if url.scheme() == "imwald" and url.host() == "note": if url.scheme() == "imwald" and url.host() == "note":
eid = (url.path() or "").strip("/") eid = (url.path() or "").strip("/")
if len(eid) == 64 and all(c in "0123456789abcdef" for c in eid.lower()): if len(eid) == 64 and all(c in "0123456789abcdef" for c in eid.lower()):
self.open_note.emit(eid.lower()) self.open_note_new_tab.emit(eid.lower())
return return
if s.startswith("https://") or s.startswith("http://"): if s.startswith("https://") or s.startswith("http://"):
QDesktopServices.openUrl(url) if not try_open_media_url_in_app(self.window(), url):
QDesktopServices.openUrl(url)

45
src/imwald/ui/theme.py

@ -113,24 +113,55 @@ QHeaderView::section {{
}} }}
QTabWidget::pane {{ QTabWidget::pane {{
border: 1px solid {BORDER}; border: 1px solid {BORDER};
border-radius: 8px; border-radius: 10px;
background-color: {BG_FIELD}; background-color: {BG_FIELD};
top: -1px; top: -1px;
padding: 2px;
}}
QTabBar {{
qproperty-drawBase: 0;
background-color: transparent;
}} }}
QTabBar::tab {{ QTabBar::tab {{
background-color: {BG_CARD}; background-color: {BG_CARD};
color: {TEXT_MUTED}; color: {TEXT_DIM};
padding: 8px 16px; font-size: 15px;
margin-right: 2px; min-height: 22px;
padding: 10px 22px 11px 20px;
margin: 8px 5px 0 0;
border: 1px solid {BORDER}; border: 1px solid {BORDER};
border-bottom: none; border-bottom: none;
border-top-left-radius: 8px; border-top-left-radius: 10px;
border-top-right-radius: 8px; border-top-right-radius: 10px;
}} }}
QTabBar::tab:selected {{ QTabBar::tab:selected {{
background-color: {BG_FIELD}; background-color: {BG_FIELD};
color: {ACCENT}; color: {TEXT};
font-weight: 600; font-weight: 600;
padding-bottom: 12px;
margin-bottom: -1px;
border-color: {BORDER};
border-bottom-color: {BG_FIELD};
}}
QTabBar::tab:!selected:hover {{
background-color: {BG_CODE};
color: {TEXT};
}}
QTabBar::close-button {{
subcontrol-origin: padding;
subcontrol-position: right;
width: 22px;
height: 22px;
margin: 0 2px 0 8px;
padding: 4px;
border-radius: 6px;
}}
QTabBar::close-button:hover {{
background-color: {BG_CARD};
border: 1px solid {ACCENT_SOFT};
}}
QTabBar::close-button:pressed {{
background-color: {BG_CODE};
}} }}
QComboBox {{ QComboBox {{
background-color: {BG_FIELD}; background-color: {BG_FIELD};

20
tests/test_forest_avatar.py

@ -0,0 +1,20 @@
"""Forest placeholder art bytes."""
from imwald.core.forest_avatar import build_forest_avatar_png, build_forest_banner_png
def test_forest_avatar_png_magic() -> None:
b = build_forest_avatar_png()
assert b.startswith(b"\x89PNG\r\n\x1a\n")
def test_forest_banner_png_magic_and_seed_variation() -> None:
a = build_forest_banner_png(seed=1)
b = build_forest_banner_png(seed=2)
assert a.startswith(b"\x89PNG\r\n\x1a\n")
assert b.startswith(b"\x89PNG\r\n\x1a\n")
assert a != b
def test_forest_avatar_seed_changes_bytes() -> None:
assert build_forest_avatar_png(seed=1) != build_forest_avatar_png(seed=2)

16
tests/test_media_viewer.py

@ -0,0 +1,16 @@
"""Media URL classification for the in-app viewer."""
from PySide6.QtCore import QUrl
from imwald.ui.media_viewer_dialog import classify_media_url
def test_classify_by_extension() -> None:
assert classify_media_url(QUrl("https://cdn/x/photo.webp")) == "image"
assert classify_media_url(QUrl("https://x.com/a/b.MP4?q=1")) == "video"
assert classify_media_url(QUrl("https://h/audio.opus")) == "audio"
def test_classify_unknown() -> None:
assert classify_media_url(QUrl("https://x.com/page")) is None
assert classify_media_url(QUrl("imwald://pub/abcd")) is None

21
tests/test_nip05_collect.py

@ -0,0 +1,21 @@
"""NIP-05 identifier collection and parsing."""
from imwald.core.kind0_profile import collect_nip05_identifiers
from imwald.core.nip05 import parse_nip05_identifier
def test_parse_nip05_identifier() -> None:
assert parse_nip05_identifier("bob@Example.com") == ("bob", "example.com")
assert parse_nip05_identifier("not an id") is None
def test_collect_from_json_and_tags_and_scan() -> None:
content = '{"name":"x","nip05":"a@b.co"} extra@scan.org tail'
tags: list[list[str]] = [["nip05", "c@d.co"], ["nip-05", "e@f.co"], ["client", "x"]]
got = collect_nip05_identifiers(content, tags)
assert got == ["a@b.co", "c@d.co", "e@f.co", "extra@scan.org"]
def test_collect_dedupes() -> None:
content = '{"nip05":"Same@X.org"} same@x.org'
assert collect_nip05_identifiers(content, []) == ["Same@X.org"]
Loading…
Cancel
Save